From ee9fc6db38dbbee7418f8a3be2ce25eaa76f90ec Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Fri, 7 Aug 2026 18:34:59 -0500 Subject: fix: add blur op for mature deviations with blur-constrained tokens Mature deviations are signed with a watermark-service token whose obj carries a "blur": ">=N" constraint. The composed wixmp /v1/fit transform had no blur operation, so wixmp rejected it with 403, which the proxy passed through as a broken image. buildMediaURL now decodes the token and, when it demands a blur, appends a matching blur_N op to the transform (e.g. w_1280,h_1920,blur_10). Unconstrained media and tokens that don't parse are left untouched, so only media that requires it is affected. Closes #14 --- app/cache.go | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) (limited to 'app/cache.go') diff --git a/app/cache.go b/app/cache.go index e4822db..6142178 100755 --- a/app/cache.go +++ b/app/cache.go @@ -4,11 +4,14 @@ package app import ( "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive + "encoding/base64" "encoding/hex" + "encoding/json" "io" "net/url" "os" "regexp" + "strconv" "strings" "sync" "syscall" @@ -88,6 +91,69 @@ func ageMemCache() { // than escaped, because this label is what selects the host to fetch from. var mediaSubdomain = regexp.MustCompile(`^[a-zA-Z0-9-]+$`) +// blurConstraint reports the minimum blur radius a wixmp media token demands, or +// 0 if it demands none. +// +// DeviantArt signs mature-content media with a watermark-service token whose obj +// carries a "blur": ">=N" constraint. wixmp then rejects a plain /v1/fit +// transform with 403 unless it includes a matching blur_N operation, so this is +// what tells buildMediaURL when to add one. A token it cannot parse yields 0, +// leaving the URL untouched — the same behaviour as before this check existed. +func blurConstraint(token string) int { + // A JWT is header.payload.signature; the claims are the middle segment, + // base64url-encoded without padding. + parts := strings.SplitN(token, ".", 3) + if len(parts) < 2 { + return 0 + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return 0 + } + + var claims struct { + Obj [][]struct { + Blur string `json:"blur"` + } `json:"obj"` + } + if json.Unmarshal(payload, &claims) != nil || + len(claims.Obj) == 0 || len(claims.Obj[0]) == 0 { + return 0 + } + + // The constraint reads like ">=10"; take its digits as the radius, which is + // the minimum the token accepts. + n := 0 + for _, c := range claims.Obj[0][0].Blur { + if c >= '0' && c <= '9' { + n = n*10 + int(c-'0') + } + } + return n +} + +// addBlurToTransform inserts a blur_n operation into a wixmp /v1/fit transform, +// turning e.g. w_1280,h_1920 into w_1280,h_1920,blur_n. It returns path +// unchanged when it carries no /v1/fit transform (GIFs and oversized originals +// are served without one) or already blurs. +func addBlurToTransform(path string, n int) string { + const marker = "/v1/fit/" + start := strings.Index(path, marker) + if start < 0 { + return path + } + ops := start + len(marker) + end := strings.IndexByte(path[ops:], '/') + if end < 0 { + return path + } + end += ops + if strings.Contains(path[ops:end], "blur_") { + return path + } + return path[:end] + ",blur_" + strconv.Itoa(n) + path[end:] +} + // buildMediaURL returns the wixmp CDN URL for one media item, reporting false // when subdomain is not a bare hostname label. // @@ -102,6 +168,13 @@ func buildMediaURL(subdomain, path, token string) (string, bool) { return "", false } + // Mature media is signed with a token that only authorizes a blurred render; + // without a matching blur op in the transform wixmp answers 403. Add the op + // the token demands, and only then, so unconstrained media is left as-is. + if n := blurConstraint(token); n > 0 { + path = addBlurToTransform(path, n) + } + // Fields rather than concatenation: String escapes the path, so a decoded // "#" or "?" in it stays part of the path instead of ending it. The host is // checked above rather than escaped, because url.URL passes it through -- cgit v1.2.3