krz/skunky-art
Alternative privacy frontend for DeviantArt.
clone: git clone https://gitbay.org/krz/skunky-art.git
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/hex"
8 "io"
9 "net/url"
10 "os"
11 "regexp"
12 "strings"
13 "sync"
14 "syscall"
15 "time"
16)
17
18type file struct {
19 Score int
20 Content []byte
21}
22
23// tempFS is the in-memory media cache, guarded by mx. A plain Mutex rather than
24// an RWMutex on purpose: every operation here mutates something (a read bumps
25// Score), and the previous code took an RLock to write, which is not exclusive.
26var tempFS = make(map[[20]byte]*file)
27var mx sync.Mutex
28
29// memGet returns the cached body for key and raises its score so that popular
30// entries outlive the janitor, or nil when the entry is absent or still empty.
31func memGet(key [20]byte) []byte {
32 mx.Lock()
33 defer mx.Unlock()
34
35 f := tempFS[key]
36 if f == nil || f.Content == nil {
37 return nil
38 }
39 f.Score += 2
40 return f.Content
41}
42
43// memPut caches body under key. An empty body is not cached, so a failed fetch
44// cannot poison the cache with a zero-length image.
45func memPut(key [20]byte, body []byte) {
46 if len(body) == 0 {
47 return
48 }
49
50 mx.Lock()
51 defer mx.Unlock()
52 tempFS[key] = &file{Content: body}
53}
54
55// InitMemCacheJanitor ages the in-memory cache forever, dropping entries whose
56// score has run out. Run it in its own goroutine, once, and only when memcache
57// is enabled.
58//
59// One loop ages the whole map. The previous design started a goroutine per
60// cached file, each looping until its own entry was evicted, and each touching
61// the map without holding mx — a concurrent map read and write, which the Go
62// runtime treats as a fatal error that recover cannot catch.
63func InitMemCacheJanitor() {
64 for {
65 time.Sleep(1 * time.Minute)
66 ageMemCache()
67 }
68}
69
70// ageMemCache runs one round of aging: every entry loses a point, and entries
71// that are already out of points are dropped. An entry starts at zero, so a body
72// nothing asks for again is gone within a round.
73func ageMemCache() {
74 mx.Lock()
75 defer mx.Unlock()
76
77 for k, f := range tempFS {
78 if f.Score <= 0 {
79 delete(tempFS, k)
80 continue
81 }
82 f.Score--
83 }
84}
85
86// mediaSubdomain matches the one hostname label wixmp media URLs vary: a hex
87// string, sometimes with dashes. Anything outside that set is rejected rather
88// than escaped, because this label is what selects the host to fetch from.
89var mediaSubdomain = regexp.MustCompile(`^[a-zA-Z0-9-]+$`)
90
91// buildMediaURL returns the wixmp CDN URL for one media item, reporting false
92// when subdomain is not a bare hostname label.
93//
94// subdomain and path arrive already percent-decoded from the request path, so
95// they can carry the characters that end a host. Concatenated into a URL string,
96// a subdomain of "x@attacker.example#" reparses as host attacker.example, with
97// "images-wixmp-x" demoted to userinfo and the intended host to a fragment —
98// pointing the fetch at whatever the caller names, including addresses reachable
99// only from the instance itself.
100func buildMediaURL(subdomain, path, token string) (string, bool) {
101 if !mediaSubdomain.MatchString(subdomain) {
102 return "", false
103 }
104
105 // Fields rather than concatenation: String escapes the path, so a decoded
106 // "#" or "?" in it stays part of the path instead of ending it. The host is
107 // checked above rather than escaped, because url.URL passes it through
108 // verbatim.
109 u := url.URL{
110 Scheme: "https",
111 Host: "images-wixmp-" + subdomain + ".wixmp.com",
112 Path: "/" + path,
113 }
114 if token != "" {
115 u.RawQuery = url.Values{"token": {token}}.Encode()
116 }
117 return u.String(), true
118}
119
120// DownloadAndSendMedia proxies one image from DeviantArt's wixmp CDN to the
121// client, serving it from the on-disk or in-memory cache when enabled. It
122// responds 403 when proxying is turned off for this instance.
123func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
124 mediaURL, ok := buildMediaURL(subdomain, path, s.Args.Get("token"))
125 if !ok {
126 s.ReturnHTTPError(400)
127 return
128 }
129
130 var response []byte
131
132 switch {
133 case CFG.Cache.Enabled:
134 key := sha1.Sum([]byte(subdomain + path)) //nolint:gosec // G401: cache-key hash, not a security primitive
135 filePath := CFG.Cache.Path + "/" + hex.EncodeToString(key[:])
136
137 if CFG.Cache.MemCache {
138 if cached := memGet(key); cached != nil {
139 response = cached
140 break
141 }
142 }
143
144 body, ok := s.loadOrFetchMedia(filePath, mediaURL)
145 if !ok {
146 // loadOrFetchMedia has already written the error response.
147 return
148 }
149 response = body
150
151 if CFG.Cache.MemCache {
152 memPut(key, response)
153 }
154 case CFG.Proxy:
155 dwnld := Download(mediaURL)
156 if dwnld.Status != 200 {
157 s.ReturnHTTPError(dwnld.Status)
158 return
159 }
160 response = dwnld.Body
161 default:
162 s.Writer.WriteHeader(403)
163 response = []byte("Sorry, butt proxy on this instance are disabled.")
164 }
165
166 _, _ = s.Writer.Write(response)
167}
168
169// loadOrFetchMedia returns the media body for filePath, preferring the on-disk
170// cache and falling back to fetching mediaURL, which it then writes back to the
171// cache. It reports false when it has already written an error response, so the
172// caller must not write anything further.
173func (s skunkyart) loadOrFetchMedia(filePath, mediaURL string) ([]byte, bool) {
174 // filePath is built from a SHA-1 of the request, not from user input, so it
175 // cannot escape the cache directory.
176 if f, err := os.Open(filePath); err == nil { //nolint:gosec // G304: path is a hash, not user-controlled
177 defer func() { try(f.Close()) }()
178
179 if body, err := io.ReadAll(f); err == nil {
180 return body, true
181 } else {
182 // An unreadable cache entry is not fatal; re-fetch it instead.
183 try(err)
184 }
185 }
186
187 dwnld := Download(mediaURL)
188 if dwnld.Status != 200 || !strings.HasPrefix(dwnld.Headers.Get("Content-Type"), "image") {
189 s.ReturnHTTPError(dwnld.Status)
190 return nil, false
191 }
192
193 try(os.WriteFile(filePath, dwnld.Body, 0600))
194 return dwnld.Body, true
195}
196
197// InitCacheSystem runs the cache rotation loop forever, evicting files past
198// their lifetime and emptying the cache when it outgrows max-size. Run it in its
199// own goroutine.
200func InitCacheSystem() {
201 c := &CFG.Cache
202 for {
203 dir, err := os.ReadDir(c.Path)
204 if err != nil {
205 if os.IsNotExist(err) {
206 try(os.Mkdir(c.Path, 0700))
207 continue
208 }
209 println(err.Error())
210 }
211
212 var total int64
213 for _, file := range dir {
214 fileName := c.Path + "/" + file.Name()
215 fileInfo, err := file.Info()
216 try(err)
217
218 if c.Lifetime != "" {
219 now := time.Now().UnixMilli()
220
221 // Sys() is platform-specific and only documented to be a
222 // *syscall.Stat_t on unix; skip rotation rather than panic
223 // if the filesystem reports something else.
224 if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok {
225 if statTime(stat)+lifetimeParsed <= now {
226 try(os.RemoveAll(fileName))
227 }
228 }
229 }
230
231 total += fileInfo.Size()
232 // if c.MaxSize != 0 && fileInfo.Size() > c.MaxSize {
233 // try(os.RemoveAll(fileName))
234 // }
235 }
236
237 if c.MaxSize != 0 && total > c.MaxSize {
238 try(os.RemoveAll(c.Path))
239 try(os.Mkdir(c.Path, 0700))
240 }
241
242 time.Sleep(time.Second * time.Duration(c.UpdateInterval))
243 }
244}