krz/skunky-art

Alternative privacy frontend for DeviantArt.

clone: git clone https://gitbay.org/krz/skunky-art.git

ee9fc6db38dbbee7418f8a3be2ce25eaa76f90ec

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-08-07T23:34:59Z

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 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 app/cache_test.go | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 150 insertions(+)

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) {