summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-08-07 18:34:59 -0500
committerChristian Cleberg <[email protected]>2026-08-07 18:45:03 -0500
commitee9fc6db38dbbee7418f8a3be2ce25eaa76f90ec (patch)
treee89f137e9814106ba54f7c5735773e9bfa2f3b60
parentddb8aa297962379fa7731032140221ba35f84b50 (diff)
downloadskunky-art-ee9fc6db38dbbee7418f8a3be2ce25eaa76f90ec.tar.gz
skunky-art-ee9fc6db38dbbee7418f8a3be2ce25eaa76f90ec.tar.bz2
skunky-art-ee9fc6db38dbbee7418f8a3be2ce25eaa76f90ec.zip
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
-rwxr-xr-xapp/cache.go73
-rw-r--r--app/cache_test.go77
2 files changed, 150 insertions, 0 deletions
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
diff --git a/app/cache_test.go b/app/cache_test.go
index eafd8b1..4fcae94 100644
--- a/app/cache_test.go
+++ b/app/cache_test.go
@@ -2,8 +2,11 @@ package app
import (
"bytes"
+ "encoding/base64"
+ "encoding/json"
"net/http/httptest"
"net/url"
+ "strings"
"sync"
"testing"
)
@@ -46,6 +49,80 @@ func TestBuildMediaURLRejectsForgedSubdomain(t *testing.T) {
}
}
+// makeMediaToken builds a JWT-shaped token whose obj carries the given blur
+// value, mirroring the wixmp media tokens DeviantArt signs. Pass a ">=N" string
+// for a blur-constrained (mature) token, or nil for an unconstrained one.
+func makeMediaToken(t *testing.T, blur any) string {
+ t.Helper()
+ claims := map[string]any{
+ "obj": [][]map[string]any{{{"path": "/f/x.png", "blur": blur}}},
+ }
+ payload, err := json.Marshal(claims)
+ if err != nil {
+ t.Fatal(err)
+ }
+ enc := base64.RawURLEncoding.EncodeToString
+ return enc([]byte(`{"alg":"none"}`)) + "." + enc(payload) + ".sig"
+}
+
+// TestBlurConstraint is the regression test for the mature-media 403: a token
+// whose obj demands a blur must yield that radius, and anything else must yield
+// 0 so the transform is left untouched.
+func TestBlurConstraint(t *testing.T) {
+ if got := blurConstraint(makeMediaToken(t, ">=10")); got != 10 {
+ t.Errorf("blur-constrained token: got %d, want 10", got)
+ }
+ if got := blurConstraint(makeMediaToken(t, nil)); got != 0 {
+ t.Errorf("null-blur token: got %d, want 0", got)
+ }
+ // Nothing parseable as a claims payload: fail open, leaving the URL alone.
+ for _, tok := range []string{"", "not-a-jwt", "a.b", "a.!!!.c"} {
+ if got := blurConstraint(tok); got != 0 {
+ t.Errorf("unparseable token %q: got %d, want 0", tok, got)
+ }
+ }
+}
+
+// TestAddBlurToTransform checks the string surgery: a blur op is inserted into a
+// /v1/fit transform, paths without one are untouched, and an existing op is not
+// doubled.
+func TestAddBlurToTransform(t *testing.T) {
+ got := addBlurToTransform("f/u/x.png/v1/fit/w_1280,h_1920/x.png", 10)
+ if want := "f/u/x.png/v1/fit/w_1280,h_1920,blur_10/x.png"; got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ if got := addBlurToTransform("f/u/x.gif", 10); got != "f/u/x.gif" {
+ t.Errorf("path without a transform was modified: %q", got)
+ }
+ blurred := "f/u/x.png/v1/fit/w_1280,h_1920,blur_10/x.png"
+ if got := addBlurToTransform(blurred, 10); got != blurred {
+ t.Errorf("existing blur op was doubled: %q", got)
+ }
+}
+
+// TestBuildMediaURLAddsBlurWhenTokenDemandsIt drives the whole path: a
+// blur-constrained token gains a matching blur op in the composed URL, and an
+// unconstrained one does not.
+func TestBuildMediaURLAddsBlurWhenTokenDemandsIt(t *testing.T) {
+ path := "f/u/x.png/v1/fit/w_1280,h_1920/x.png"
+
+ got, ok := buildMediaURL("ed30a86b", path, makeMediaToken(t, ">=10"))
+ if !ok {
+ t.Fatal("a plain label was rejected, want accepted")
+ }
+ u, err := url.Parse(got)
+ if err != nil {
+ t.Fatalf("built an unparseable URL %q: %v", got, err)
+ }
+ if !strings.Contains(u.Path, "w_1280,h_1920,blur_10") {
+ t.Errorf("transform is %q, want a blur_10 op added", u.Path)
+ }
+
+ if got, _ := buildMediaURL("ed30a86b", path, makeMediaToken(t, nil)); strings.Contains(got, "blur") {
+ t.Errorf("unconstrained media gained a blur op: %q", got)
+ }
+}
+
// TestBuildMediaURLKeepsHostOnWixmp is the property that actually matters: for
// anything accepted, the host the client ends up talking to is the CDN.
func TestBuildMediaURLKeepsHostOnWixmp(t *testing.T) {