krz/skunky-art
Alternative privacy frontend for DeviantArt.
clone: git clone https://gitbay.org/krz/skunky-art.git
sonarcloud-cleanup: app/cache.go · raw
1package app
2
3// TODO: implement JSON caching and clean up the code.
4
5import (
6 "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive
7 "encoding/base64"
8 "encoding/hex"
9 "encoding/json"
10 "io"
11 "net/url"
12 "os"
13 "regexp"
14 "strconv"
15 "strings"
16 "sync"
17 "syscall"
18 "time"
19)
20
21type file struct {
22 Score int
23 Content []byte
24}
25
26// tempFS is the in-memory media cache, guarded by mx. A plain Mutex rather than
27// an RWMutex on purpose: every operation here mutates something (a read bumps
28// Score), and the previous code took an RLock to write, which is not exclusive.
29var tempFS = make(map[[20]byte]*file)
30var mx sync.Mutex
31
32// memGet returns the cached body for key and raises its score so that popular
33// entries outlive the janitor, or nil when the entry is absent or still empty.
34func memGet(key [20]byte) []byte {
35 mx.Lock()
36 defer mx.Unlock()
37
38 f := tempFS[key]
39 if f == nil || f.Content == nil {
40 return nil
41 }
42 f.Score += 2
43 return f.Content
44}
45
46// memPut caches body under key. An empty body is not cached, so a failed fetch
47// cannot poison the cache with a zero-length image.
48func memPut(key [20]byte, body []byte) {
49 if len(body) == 0 {
50 return
51 }
52
53 mx.Lock()
54 defer mx.Unlock()
55 tempFS[key] = &file{Content: body}
56}
57
58// InitMemCacheJanitor ages the in-memory cache forever, dropping entries whose
59// score has run out. Run it in its own goroutine, once, and only when memcache
60// is enabled.
61//
62// One loop ages the whole map. The previous design started a goroutine per
63// cached file, each looping until its own entry was evicted, and each touching
64// the map without holding mx — a concurrent map read and write, which the Go
65// runtime treats as a fatal error that recover cannot catch.
66func InitMemCacheJanitor() {
67 for {
68 time.Sleep(1 * time.Minute)
69 ageMemCache()
70 }
71}
72
73// ageMemCache runs one round of aging: every entry loses a point, and entries
74// that are already out of points are dropped. An entry starts at zero, so a body
75// nothing asks for again is gone within a round.
76func ageMemCache() {
77 mx.Lock()
78 defer mx.Unlock()
79
80 for k, f := range tempFS {
81 if f.Score <= 0 {
82 delete(tempFS, k)
83 continue
84 }
85 f.Score--
86 }
87}
88
89// mediaSubdomain matches the one hostname label wixmp media URLs vary: a hex
90// string, sometimes with dashes. Anything outside that set is rejected rather
91// than escaped, because this label is what selects the host to fetch from.
92var mediaSubdomain = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
93
94// blurConstraint reports the minimum blur radius a wixmp media token demands, or
95// 0 if it demands none.
96//
97// DeviantArt signs mature-content media with a watermark-service token whose obj
98// carries a "blur": ">=N" constraint. wixmp then rejects a plain /v1/fit
99// transform with 403 unless it includes a matching blur_N operation, so this is
100// what tells buildMediaURL when to add one. A token it cannot parse yields 0,
101// leaving the URL untouched — the same behaviour as before this check existed.
102func blurConstraint(token string) int {
103 // A JWT is header.payload.signature; the claims are the middle segment,
104 // base64url-encoded without padding.
105 parts := strings.SplitN(token, ".", 3)
106 if len(parts) < 2 {
107 return 0
108 }
109 payload, err := base64.RawURLEncoding.DecodeString(parts[1])
110 if err != nil {
111 return 0
112 }
113
114 var claims struct {
115 Obj [][]struct {
116 Blur string `json:"blur"`
117 } `json:"obj"`
118 }
119 if json.Unmarshal(payload, &claims) != nil ||
120 len(claims.Obj) == 0 || len(claims.Obj[0]) == 0 {
121 return 0
122 }
123
124 // The constraint reads like ">=10"; take its digits as the radius, which is
125 // the minimum the token accepts.
126 n := 0
127 for _, c := range claims.Obj[0][0].Blur {
128 if c >= '0' && c <= '9' {
129 n = n*10 + int(c-'0')
130 }
131 }
132 return n
133}
134
135// addBlurToTransform inserts a blur_n operation into a wixmp /v1/fit transform,
136// turning e.g. w_1280,h_1920 into w_1280,h_1920,blur_n. It returns path
137// unchanged when it carries no /v1/fit transform (GIFs and oversized originals
138// are served without one) or already blurs.
139func addBlurToTransform(path string, n int) string {
140 const marker = "/v1/fit/"
141 start := strings.Index(path, marker)
142 if start < 0 {
143 return path
144 }
145 ops := start + len(marker)
146 end := strings.IndexByte(path[ops:], '/')
147 if end < 0 {
148 return path
149 }
150 end += ops
151 if strings.Contains(path[ops:end], "blur_") {
152 return path
153 }
154 return path[:end] + ",blur_" + strconv.Itoa(n) + path[end:]
155}
156
157// buildMediaURL returns the wixmp CDN URL for one media item, reporting false
158// when subdomain is not a bare hostname label.
159//
160// subdomain and path arrive already percent-decoded from the request path, so
161// they can carry the characters that end a host. Concatenated into a URL string,
162// a subdomain of "x@attacker.example#" reparses as host attacker.example, with
163// "images-wixmp-x" demoted to userinfo and the intended host to a fragment —
164// pointing the fetch at whatever the caller names, including addresses reachable
165// only from the instance itself.
166func buildMediaURL(subdomain, path, token string) (string, bool) {
167 if !mediaSubdomain.MatchString(subdomain) {
168 return "", false
169 }
170
171 // Mature media is signed with a token that only authorizes a blurred render;
172 // without a matching blur op in the transform wixmp answers 403. Add the op
173 // the token demands, and only then, so unconstrained media is left as-is.
174 if n := blurConstraint(token); n > 0 {
175 path = addBlurToTransform(path, n)
176 }
177
178 // Fields rather than concatenation: String escapes the path, so a decoded
179 // "#" or "?" in it stays part of the path instead of ending it. The host is
180 // checked above rather than escaped, because url.URL passes it through
181 // verbatim.
182 u := url.URL{
183 Scheme: "https",
184 Host: "images-wixmp-" + subdomain + ".wixmp.com",
185 Path: "/" + path,
186 }
187 if token != "" {
188 u.RawQuery = url.Values{"token": {token}}.Encode()
189 }
190 return u.String(), true
191}
192
193// DownloadAndSendMedia proxies one image from DeviantArt's wixmp CDN to the
194// client, serving it from the on-disk or in-memory cache when enabled. It
195// responds 403 when proxying is turned off for this instance.
196func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
197 mediaURL, ok := buildMediaURL(subdomain, path, s.Args.Get("token"))
198 if !ok {
199 s.ReturnHTTPError(400)
200 return
201 }
202
203 var response []byte
204
205 switch {
206 case CFG.Cache.Enabled:
207 key := sha1.Sum([]byte(subdomain + path)) //nolint:gosec // G401: cache-key hash, not a security primitive
208 filePath := CFG.Cache.Path + "/" + hex.EncodeToString(key[:])
209
210 if CFG.Cache.MemCache {
211 if cached := memGet(key); cached != nil {
212 response = cached
213 break
214 }
215 }
216
217 body, ok := s.loadOrFetchMedia(filePath, mediaURL)
218 if !ok {
219 // loadOrFetchMedia has already written the error response.
220 return
221 }
222 response = body
223
224 if CFG.Cache.MemCache {
225 memPut(key, response)
226 }
227 case CFG.Proxy:
228 dwnld := Download(mediaURL)
229 if dwnld.Status != 200 {
230 s.ReturnHTTPError(dwnld.Status)
231 return
232 }
233 response = dwnld.Body
234 default:
235 s.Writer.WriteHeader(403)
236 response = []byte("Sorry, butt proxy on this instance are disabled.")
237 }
238
239 _, _ = s.Writer.Write(response)
240}
241
242// loadOrFetchMedia returns the media body for filePath, preferring the on-disk
243// cache and falling back to fetching mediaURL, which it then writes back to the
244// cache. It reports false when it has already written an error response, so the
245// caller must not write anything further.
246func (s skunkyart) loadOrFetchMedia(filePath, mediaURL string) ([]byte, bool) {
247 // filePath is built from a SHA-1 of the request, not from user input, so it
248 // cannot escape the cache directory.
249 if f, err := os.Open(filePath); err == nil { //nolint:gosec // G304: path is a hash, not user-controlled
250 defer func() { try(f.Close()) }()
251
252 if body, err := io.ReadAll(f); err == nil {
253 return body, true
254 } else {
255 // An unreadable cache entry is not fatal; re-fetch it instead.
256 try(err)
257 }
258 }
259
260 dwnld := Download(mediaURL)
261 if dwnld.Status != 200 || !strings.HasPrefix(dwnld.Headers.Get("Content-Type"), "image") {
262 s.ReturnHTTPError(dwnld.Status)
263 return nil, false
264 }
265
266 try(os.WriteFile(filePath, dwnld.Body, 0600))
267 return dwnld.Body, true
268}
269
270// InitCacheSystem runs the cache rotation loop forever, evicting files past
271// their lifetime and emptying the cache when it outgrows max-size. Run it in its
272// own goroutine.
273func InitCacheSystem() {
274 c := &CFG.Cache
275 for {
276 dir, err := os.ReadDir(c.Path)
277 if err != nil {
278 if os.IsNotExist(err) {
279 try(os.Mkdir(c.Path, 0700))
280 continue
281 }
282 println(err.Error())
283 }
284
285 var total int64
286 for _, file := range dir {
287 fileName := c.Path + "/" + file.Name()
288 fileInfo, err := file.Info()
289 try(err)
290
291 if c.Lifetime != "" {
292 now := time.Now().UnixMilli()
293
294 // Sys() is platform-specific and only documented to be a
295 // *syscall.Stat_t on unix; skip rotation rather than panic
296 // if the filesystem reports something else.
297 if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok {
298 if statTime(stat)+lifetimeParsed <= now {
299 try(os.RemoveAll(fileName))
300 }
301 }
302 }
303
304 total += fileInfo.Size()
305 // if c.MaxSize != 0 && fileInfo.Size() > c.MaxSize {
306 // try(os.RemoveAll(fileName))
307 // }
308 }
309
310 if c.MaxSize != 0 && total > c.MaxSize {
311 try(os.RemoveAll(c.Path))
312 try(os.Mkdir(c.Path, 0700))
313 }
314
315 time.Sleep(time.Second * time.Duration(c.UpdateInterval))
316 }
317}