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:
@@ -66,10 +66,10 @@ func DefaultPresenterConfig(baseURL string) PresenterConfig {
|
|||||||
return PresenterConfig{
|
return PresenterConfig{
|
||||||
APIBaseURL: baseURL, // always API base URL
|
APIBaseURL: baseURL, // always API base URL
|
||||||
BaseURL: baseURL, // can change to custom domain when needed
|
BaseURL: baseURL, // can change to custom domain when needed
|
||||||
PoweredByLogoPath: brand.DefaultPoweredByLogoPath,
|
PoweredByLogoPath: brand.StaticPath(brand.PoweredByLogo),
|
||||||
SenderCompanyName: "Probo",
|
SenderCompanyName: "Probo",
|
||||||
SenderCompanyWebsiteURL: "https://www.probo.com",
|
SenderCompanyWebsiteURL: "https://www.probo.com",
|
||||||
SenderCompanyLogoPath: brand.DefaultSenderCompanyLogoPath,
|
SenderCompanyLogoPath: brand.StaticPath(brand.SenderCompanyLogo),
|
||||||
SenderCompanyHeadquarterAddress: "Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US",
|
SenderCompanyHeadquarterAddress: "Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,24 +2,18 @@ package brand
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"io/fs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
//go:embed assets
|
//go:embed assets
|
||||||
staticAssets embed.FS
|
var staticAssets embed.FS
|
||||||
|
|
||||||
Assets fs.FS
|
// Logical brand assets. These filenames must exist under assets/; NewAssets
|
||||||
|
// verifies their presence at startup so a rename or removal fails fast instead
|
||||||
DefaultPoweredByLogoPath string = "/api/files/v1/static/probo-gray-small.png"
|
// of silently producing a 404 in the consumers (e.g. emails).
|
||||||
DefaultSenderCompanyLogoPath string = "/api/files/v1/static/probo.png"
|
const (
|
||||||
|
PoweredByLogo = "probo-gray-small.png"
|
||||||
|
SenderCompanyLogo = "probo.png"
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
// requiredAssets are validated to exist whenever an Assets is constructed.
|
||||||
var err error
|
var requiredAssets = []string{PoweredByLogo, SenderCompanyLogo}
|
||||||
|
|
||||||
Assets, err = fs.Sub(staticAssets, "assets")
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
103
pkg/brand/http.go
Normal file
103
pkg/brand/http.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package brand
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StaticPathPrefix is the single source of truth for the public URL path under
|
||||||
|
// which brand assets are served. It must stay in sync with the route mounted by
|
||||||
|
// the files API handler (/api -> /files/v1 -> /static/{file}).
|
||||||
|
const StaticPathPrefix = "/api/files/v1/static"
|
||||||
|
|
||||||
|
// StaticPath returns the public URL path that serves the named brand asset.
|
||||||
|
func StaticPath(name string) string {
|
||||||
|
return path.Join(StaticPathPrefix, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assets provides access to the embedded brand assets together with their
|
||||||
|
// precomputed content-hash ETags.
|
||||||
|
type Assets struct {
|
||||||
|
fs fs.FS
|
||||||
|
etags map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAssets builds an Assets from the embedded brand files. It panics on error
|
||||||
|
// because the assets are embedded at build time and a failure here is a
|
||||||
|
// programming error, not a runtime condition.
|
||||||
|
func NewAssets() *Assets {
|
||||||
|
assetsFS, err := fs.Sub(staticAssets, "assets")
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("cannot open brand assets: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range requiredAssets {
|
||||||
|
if _, err := fs.Stat(assetsFS, name); err != nil {
|
||||||
|
panic(fmt.Errorf("missing required brand asset %q: %w", name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Assets{
|
||||||
|
fs: assetsFS,
|
||||||
|
etags: mustComputeAssetETags(assetsFS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stat returns file information for the named asset.
|
||||||
|
func (a *Assets) Stat(name string) (fs.FileInfo, error) {
|
||||||
|
return fs.Stat(a.fs, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeAssets writes the named asset to the response. It delegates to
|
||||||
|
// http.ServeFileFS, which sets Content-Type, handles range requests, and honors
|
||||||
|
// a pre-set ETag response header for If-None-Match (304 Not Modified).
|
||||||
|
func (a *Assets) ServeAssets(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||||
|
|
||||||
|
if etag, ok := a.etags[name]; ok {
|
||||||
|
w.Header().Set("ETag", `"`+etag+`"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeFileFS(w, r, a.fs, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustComputeAssetETags precomputes content-hash ETags for every embedded
|
||||||
|
// brand asset, mirroring the MD5 walk used by the SPA static handler. It panics
|
||||||
|
// on error because the assets are embedded at build time and a failure here is
|
||||||
|
// a programming error, not a runtime condition.
|
||||||
|
func mustComputeAssetETags(assets fs.FS) map[string]string {
|
||||||
|
etags := make(map[string]string)
|
||||||
|
|
||||||
|
err := fs.WalkDir(
|
||||||
|
assets,
|
||||||
|
".",
|
||||||
|
func(name string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if d.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := fs.ReadFile(assets, name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read brand asset %q: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := md5.Sum(content)
|
||||||
|
etags[name] = hex.EncodeToString(hash[:])
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(fmt.Errorf("cannot compute brand asset etags: %w", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return etags
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ import (
|
|||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/cookiebanner"
|
"go.probo.inc/probo/pkg/cookiebanner"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"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 {
|
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")
|
bannerIDStr := chi.URLParam(r, "bannerID")
|
||||||
if bannerIDStr == "" {
|
if bannerIDStr == "" {
|
||||||
jsonutil.RenderForbidden(w)
|
jsonx.RenderForbidden(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bannerID, err := gid.ParseGID(bannerIDStr)
|
bannerID, err := gid.ParseGID(bannerIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonutil.RenderForbidden(w)
|
jsonx.RenderForbidden(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
banner, err := cookieBannerSvc.GetActiveCookieBanner(r.Context(), bannerID)
|
banner, err := cookieBannerSvc.GetActiveCookieBanner(r.Context(), bannerID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
||||||
jsonutil.RenderForbidden(w)
|
jsonx.RenderForbidden(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.ErrorCtx(r.Context(), "cannot load cookie banner for CORS check", log.Error(err))
|
logger.ErrorCtx(r.Context(), "cannot load cookie banner for CORS check", log.Error(err))
|
||||||
jsonutil.RenderInternalServerError(w)
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
canonicalOrigin := cookiebanner.CanonicalizeOrigin(origin)
|
canonicalOrigin := cookiebanner.CanonicalizeOrigin(origin)
|
||||||
if banner.Origin != canonicalOrigin {
|
if banner.Origin != canonicalOrigin {
|
||||||
jsonutil.RenderForbidden(w)
|
jsonx.RenderForbidden(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/geoloc"
|
"go.probo.inc/probo/pkg/geoloc"
|
||||||
"go.probo.inc/probo/pkg/gid"
|
"go.probo.inc/probo/pkg/gid"
|
||||||
"go.probo.inc/probo/pkg/server/api/clientip"
|
"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"
|
"go.probo.inc/probo/pkg/uri"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ func NewMux(
|
|||||||
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
|
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
|
jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
|
||||||
return
|
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)
|
config, err := h.cookieBannerSvc.GetActiveBannerConfig(r.Context(), bannerID, lang, regulation, sdkVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
||||||
jsonutil.RenderNotFound(w, fmt.Errorf("banner not found"))
|
jsonx.RenderNotFound(w, fmt.Errorf("banner not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, cookiebanner.ErrNoPublishedVersion) {
|
if errors.Is(err, cookiebanner.ErrNoPublishedVersion) {
|
||||||
jsonutil.RenderNotFound(w, fmt.Errorf("no published version"))
|
jsonx.RenderNotFound(w, fmt.Errorf("no published version"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.ErrorCtx(r.Context(), "cannot get banner config", log.Error(err), log.String("sdk_version", sdkVersion))
|
h.logger.ErrorCtx(r.Context(), "cannot get banner config", log.Error(err), log.String("sdk_version", sdkVersion))
|
||||||
jsonutil.RenderInternalServerError(w)
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,25 +124,25 @@ func (h *Handler) resolveCountryCode(r *http.Request) *coredata.CountryCode {
|
|||||||
func (h *Handler) handleGetConsent(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleGetConsent(w http.ResponseWriter, r *http.Request) {
|
||||||
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
|
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
|
jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
visitorID := chi.URLParam(r, "visitorID")
|
visitorID := chi.URLParam(r, "visitorID")
|
||||||
if visitorID == "" {
|
if visitorID == "" {
|
||||||
jsonutil.RenderBadRequest(w, fmt.Errorf("missing visitor id"))
|
jsonx.RenderBadRequest(w, fmt.Errorf("missing visitor id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
consent, err := h.cookieBannerSvc.GetVisitorConsent(r.Context(), bannerID, visitorID)
|
consent, err := h.cookieBannerSvc.GetVisitorConsent(r.Context(), bannerID, visitorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
||||||
jsonutil.RenderNotFound(w, fmt.Errorf("banner not found"))
|
jsonx.RenderNotFound(w, fmt.Errorf("banner not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, cookiebanner.ErrConsentNotFound) {
|
if errors.Is(err, cookiebanner.ErrConsentNotFound) {
|
||||||
jsonutil.RenderNotFound(w, fmt.Errorf("consent not found"))
|
jsonx.RenderNotFound(w, fmt.Errorf("consent not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ func (h *Handler) handleGetConsent(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Error(err),
|
log.Error(err),
|
||||||
log.String("sdk_version", sdkVersionFromContext(r.Context())),
|
log.String("sdk_version", sdkVersionFromContext(r.Context())),
|
||||||
)
|
)
|
||||||
jsonutil.RenderInternalServerError(w)
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -179,13 +179,13 @@ type (
|
|||||||
func (h *Handler) handlePostConsent(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handlePostConsent(w http.ResponseWriter, r *http.Request) {
|
||||||
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
|
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
|
jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var body postConsentBody
|
var body postConsentBody
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,17 +214,17 @@ func (h *Handler) handlePostConsent(w http.ResponseWriter, r *http.Request) {
|
|||||||
record, err := h.cookieBannerSvc.RecordConsent(r.Context(), bannerID, req)
|
record, err := h.cookieBannerSvc.RecordConsent(r.Context(), bannerID, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
||||||
jsonutil.RenderNotFound(w, fmt.Errorf("banner not found"))
|
jsonx.RenderNotFound(w, fmt.Errorf("banner not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, cookiebanner.ErrVersionNotFound) || errors.Is(err, cookiebanner.ErrVersionNotPublished) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.ErrorCtx(r.Context(), "cannot record consent", log.Error(err), log.String("sdk_version", sdkVersion))
|
h.logger.ErrorCtx(r.Context(), "cannot record consent", log.Error(err), log.String("sdk_version", sdkVersion))
|
||||||
jsonutil.RenderInternalServerError(w)
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -290,23 +290,23 @@ func sanitizeInitiatorURL(raw *string) *string {
|
|||||||
func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Request) {
|
||||||
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
|
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
|
jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var body reportDetectedCookiesBody
|
var body reportDetectedCookiesBody
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(body.Cookies) == 0 {
|
if len(body.Cookies) == 0 {
|
||||||
jsonutil.RenderBadRequest(w, fmt.Errorf("cookies list is empty"))
|
jsonx.RenderBadRequest(w, fmt.Errorf("cookies list is empty"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(body.Cookies) > maxDetectedCookiesPerRequest {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +340,7 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(detected) == 0 {
|
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
|
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 err := h.cookieBannerSvc.ReportDetectedCookies(r.Context(), bannerID, req); err != nil {
|
||||||
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
||||||
jsonutil.RenderNotFound(w, fmt.Errorf("banner not found"))
|
jsonx.RenderNotFound(w, fmt.Errorf("banner not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,7 +360,7 @@ func (h *Handler) handleReportDetectedCookies(w http.ResponseWriter, r *http.Req
|
|||||||
log.Error(err),
|
log.Error(err),
|
||||||
log.String("sdk_version", sdkVersionFromContext(r.Context())),
|
log.String("sdk_version", sdkVersionFromContext(r.Context())),
|
||||||
)
|
)
|
||||||
jsonutil.RenderInternalServerError(w)
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -392,24 +392,24 @@ const maxDetectedTrackersPerRequest = 100
|
|||||||
func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleReportDetectedTrackers(w http.ResponseWriter, r *http.Request) {
|
||||||
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
|
bannerID, err := gid.ParseGID(chi.URLParam(r, "bannerID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonutil.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
|
jsonx.RenderBadRequest(w, fmt.Errorf("invalid banner id"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var body reportDetectedTrackersBody
|
var body reportDetectedTrackersBody
|
||||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
total := len(body.Cookies) + len(body.Storage) + len(body.Resources)
|
total := len(body.Cookies) + len(body.Storage) + len(body.Resources)
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
jsonutil.RenderBadRequest(w, fmt.Errorf("no items provided"))
|
jsonx.RenderBadRequest(w, fmt.Errorf("no items provided"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if total > maxDetectedTrackersPerRequest {
|
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
|
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 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.cookieBannerSvc.ReportDetectedTrackers(r.Context(), bannerID, req); err != nil {
|
if err := h.cookieBannerSvc.ReportDetectedTrackers(r.Context(), bannerID, req); err != nil {
|
||||||
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
if errors.Is(err, cookiebanner.ErrBannerNotFound) {
|
||||||
jsonutil.RenderNotFound(w, fmt.Errorf("banner not found"))
|
jsonx.RenderNotFound(w, fmt.Errorf("banner not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.ErrorCtx(r.Context(), "cannot report detected trackers", log.Error(err), log.String("sdk_version", sdkVersionFromContext(r.Context())))
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ package files_v1
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,7 +30,7 @@ import (
|
|||||||
"go.probo.inc/probo/pkg/probo"
|
"go.probo.inc/probo/pkg/probo"
|
||||||
"go.probo.inc/probo/pkg/securecookie"
|
"go.probo.inc/probo/pkg/securecookie"
|
||||||
"go.probo.inc/probo/pkg/server/api/authn"
|
"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
|
const presignedURLExpiry = 1 * time.Hour
|
||||||
@@ -41,6 +40,7 @@ type Handler struct {
|
|||||||
fileSvc *filemanager.Service
|
fileSvc *filemanager.Service
|
||||||
probo *probo.Service
|
probo *probo.Service
|
||||||
iamSvc *iam.Service
|
iamSvc *iam.Service
|
||||||
|
assets *brand.Assets
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMux(
|
func NewMux(
|
||||||
@@ -56,6 +56,7 @@ func NewMux(
|
|||||||
fileSvc: fileSvc,
|
fileSvc: fileSvc,
|
||||||
probo: proboSvc,
|
probo: proboSvc,
|
||||||
iamSvc: iamSvc,
|
iamSvc: iamSvc,
|
||||||
|
assets: brand.NewAssets(),
|
||||||
}
|
}
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
@@ -77,12 +78,15 @@ func NewMux(
|
|||||||
func (h *Handler) handleGetStaticFile(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) handleGetStaticFile(w http.ResponseWriter, r *http.Request) {
|
||||||
file := chi.URLParam(r, "file")
|
file := chi.URLParam(r, "file")
|
||||||
|
|
||||||
if _, statErr := fs.Stat(brand.Assets, file); statErr == nil {
|
if _, statErr := h.assets.Stat(file); statErr != nil {
|
||||||
http.ServeFileFS(w, r, brand.Assets, file)
|
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
|
||||||
return
|
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) {
|
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)
|
fileID, err := gid.ParseGID(fileIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
|
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := h.fileSvc.GetPublicFile(r.Context(), fileID)
|
file, err := h.fileSvc.GetPublicFile(r.Context(), fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
|
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +111,7 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Error(err),
|
log.Error(err),
|
||||||
log.String("file_id", fileIDStr),
|
log.String("file_id", fileIDStr),
|
||||||
)
|
)
|
||||||
jsonutil.RenderInternalServerError(w)
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,7 +124,7 @@ func (h *Handler) handleGetPublicFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Error(err),
|
log.Error(err),
|
||||||
log.String("file_id", fileIDStr),
|
log.String("file_id", fileIDStr),
|
||||||
)
|
)
|
||||||
jsonutil.RenderInternalServerError(w)
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -133,7 +137,7 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
fileID, err := gid.ParseGID(fileIDStr)
|
fileID, err := gid.ParseGID(fileIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
|
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,19 +157,19 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
scope, err := h.iamSvc.Authorizer.Authorize(ctx, params)
|
scope, err := h.iamSvc.Authorizer.Authorize(ctx, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
|
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := h.probo.Files.Get(ctx, scope, fileID)
|
f, err := h.probo.Files.Get(ctx, scope, fileID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, coredata.ErrResourceNotFound) {
|
if errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
jsonutil.RenderNotFound(w, fmt.Errorf("file not found"))
|
jsonx.RenderNotFound(w, fmt.Errorf("file not found"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
h.logger.ErrorCtx(ctx, "cannot get file", log.Error(err), log.String("file_id", fileIDStr))
|
h.logger.ErrorCtx(ctx, "cannot get file", log.Error(err), log.String("file_id", fileIDStr))
|
||||||
jsonutil.RenderInternalServerError(w)
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -173,7 +177,7 @@ func (h *Handler) handleGetFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
presignedURL, err := h.fileSvc.GeneratePresignedURL(ctx, f, presignedURLExpiry)
|
presignedURL, err := h.fileSvc.GeneratePresignedURL(ctx, f, presignedURLExpiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.ErrorCtx(ctx, "cannot generate file URL", log.Error(err), log.String("file_id", fileIDStr))
|
h.logger.ErrorCtx(ctx, "cannot generate file URL", log.Error(err), log.String("file_id", fileIDStr))
|
||||||
jsonutil.RenderInternalServerError(w)
|
jsonx.RenderInternalServerError(w)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"go.gearno.de/kit/log"
|
"go.gearno.de/kit/log"
|
||||||
"go.probo.inc/probo/pkg/securecookie"
|
"go.probo.inc/probo/pkg/securecookie"
|
||||||
)
|
)
|
||||||
@@ -67,6 +69,43 @@ func TestHandleGetFile_InvalidGID(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusNotFound, rec.Code)
|
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) {
|
func TestHandleGetFile_UnauthenticatedReturns401(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
package jsonutil
|
package jsonx
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
Reference in New Issue
Block a user