krz/skunky-art

Alternative privacy frontend for DeviantArt.

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

v1.3.8: 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/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}