krz/skunky-art

Alternative privacy frontend for DeviantArt.

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

v1.3.4: 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	"os"
 10	"strings"
 11	"sync"
 12	"syscall"
 13	"time"
 14)
 15
 16type file struct {
 17	Score   int
 18	Content []byte
 19}
 20
 21var tempFS = make(map[[20]byte]*file)
 22var mx = &sync.RWMutex{}
 23
 24// DownloadAndSendMedia proxies one image from DeviantArt's wixmp CDN to the
 25// client, serving it from the on-disk or in-memory cache when enabled. It
 26// responds 403 when proxying is turned off for this instance.
 27func (s skunkyart) DownloadAndSendMedia(subdomain, path string) {
 28	var url strings.Builder
 29	url.WriteString("https://images-wixmp-")
 30	url.WriteString(subdomain)
 31	url.WriteString(".wixmp.com/")
 32	url.WriteString(path)
 33	if t := s.Args.Get("token"); t != "" {
 34		url.WriteString("?token=")
 35		url.WriteString(t)
 36	}
 37
 38	var response []byte
 39
 40	switch {
 41	case CFG.Cache.Enabled:
 42		fileName := sha1.Sum([]byte(subdomain + path)) //nolint:gosec // G401: cache-key hash, not a security primitive
 43		filePath := CFG.Cache.Path + "/" + hex.EncodeToString(fileName[:])
 44
 45		c := func() {
 46			// filePath is built from a SHA-1 of the request, not from user input,
 47			// so it cannot escape the cache directory.
 48			file, err := os.Open(filePath) //nolint:gosec // G304: path is a hash, not user-controlled
 49			if err != nil {
 50				dwnld := Download(url.String())
 51				if dwnld.Status == 200 && strings.HasPrefix(dwnld.Headers.Get("Content-Type"), "image") {
 52					response = dwnld.Body
 53					try(os.WriteFile(filePath, response, 0600))
 54				} else {
 55					s.ReturnHTTPError(dwnld.Status)
 56					return
 57				}
 58			} else {
 59				defer func() { try(file.Close()) }()
 60				file, e := io.ReadAll(file)
 61				try(e)
 62				response = file
 63			}
 64		}
 65
 66		if CFG.Cache.MemCache {
 67			mx.Lock()
 68			if tempFS[fileName] == nil {
 69				tempFS[fileName] = &file{}
 70			}
 71			mx.Unlock()
 72
 73			if tempFS[fileName].Content != nil {
 74				response = tempFS[fileName].Content
 75				tempFS[fileName].Score += 2
 76				break
 77			} else {
 78				c()
 79				go func() {
 80					defer restore()
 81
 82					mx.RLock()
 83					tempFS[fileName].Content = response
 84					mx.RUnlock()
 85
 86					for {
 87						time.Sleep(1 * time.Minute)
 88
 89						mx.Lock()
 90						if tempFS[fileName].Score <= 0 {
 91							delete(tempFS, fileName)
 92							mx.Unlock()
 93							return
 94						}
 95						tempFS[fileName].Score--
 96						mx.Unlock()
 97					}
 98				}()
 99			}
100		} else {
101			c()
102		}
103	case CFG.Proxy:
104		dwnld := Download(url.String())
105		if dwnld.Status != 200 {
106			s.ReturnHTTPError(dwnld.Status)
107			return
108		}
109		response = dwnld.Body
110	default:
111		s.Writer.WriteHeader(403)
112		response = []byte("Sorry, butt proxy on this instance are disabled.")
113	}
114
115	_, _ = s.Writer.Write(response)
116}
117
118// InitCacheSystem runs the cache rotation loop forever, evicting files past
119// their lifetime and emptying the cache when it outgrows max-size. Run it in its
120// own goroutine.
121func InitCacheSystem() {
122	c := &CFG.Cache
123	for {
124		dir, err := os.ReadDir(c.Path)
125		if err != nil {
126			if os.IsNotExist(err) {
127				try(os.Mkdir(c.Path, 0700))
128				continue
129			}
130			println(err.Error())
131		}
132
133		var total int64
134		for _, file := range dir {
135			fileName := c.Path + "/" + file.Name()
136			fileInfo, err := file.Info()
137			try(err)
138
139			if c.Lifetime != "" {
140				now := time.Now().UnixMilli()
141
142				// Sys() is platform-specific and only documented to be a
143				// *syscall.Stat_t on unix; skip rotation rather than panic
144				// if the filesystem reports something else.
145				if stat, ok := fileInfo.Sys().(*syscall.Stat_t); ok {
146					if statTime(stat)+lifetimeParsed <= now {
147						try(os.RemoveAll(fileName))
148					}
149				}
150			}
151
152			total += fileInfo.Size()
153			// if c.MaxSize != 0 && fileInfo.Size() > c.MaxSize {
154			// 	try(os.RemoveAll(fileName))
155			// }
156		}
157
158		if c.MaxSize != 0 && total > c.MaxSize {
159			try(os.RemoveAll(c.Path))
160			try(os.Mkdir(c.Path, 0700))
161		}
162
163		time.Sleep(time.Second * time.Duration(c.UpdateInterval))
164	}
165}