Commit 7eb5e5e223
Verified · cmc
Layout: unified · split
app/api.go +20 −11
| @@ -9,6 +9,8 @@ import ( | ||
| 9 | 9 | "github.com/zerolabsco/devianter" |
| 10 | 10 | ) |
| 11 | 11 | |
| 12 | // API serves the JSON endpoints under /api, backed by the request its main | |
| 13 | // field points at. | |
| 12 | 14 | type API struct { |
| 13 | 15 | main *skunkyart |
| 14 | 16 | } |
| @@ -18,6 +20,7 @@ type info struct { | ||
| 18 | 20 | Settings settingsParams `json:"settings"` |
| 19 | 21 | } |
| 20 | 22 | |
| 23 | // Info responds with this instance's version and its proxy/NSFW settings. | |
| 21 | 24 | func (a API) Info() { |
| 22 | 25 | json, err := json.Marshal(info{ |
| 23 | 26 | Version: a.main.Version, |
| @@ -27,9 +30,10 @@ func (a API) Info() { | ||
| 27 | 30 | }, |
| 28 | 31 | }) |
| 29 | 32 | try(err) |
| 30 | a.main.Writer.Write(json) | |
| 33 | _, _ = a.main.Writer.Write(json) | |
| 31 | 34 | } |
| 32 | 35 | |
| 36 | // Error responds with a JSON error body and the given HTTP status. | |
| 33 | 37 | func (a API) Error(description string, status int) { |
| 34 | 38 | a.main.Writer.WriteHeader(status) |
| 35 | 39 | var response strings.Builder |
| @@ -40,33 +44,38 @@ func (a API) Error(description string, status int) { | ||
| 40 | 44 | } |
| 41 | 45 | |
| 42 | 46 | func (a API) sendMedia(d *devianter.Deviation) { |
| 43 | mediaUrl, name := devianter.UrlFromMedia(d.Media) | |
| 47 | mediaURL, name := devianter.UrlFromMedia(d.Media) | |
| 44 | 48 | a.main.SetFilename(name) |
| 45 | if len(mediaUrl) != 0 { | |
| 49 | if len(mediaURL) != 0 { | |
| 46 | 50 | return |
| 47 | 51 | } |
| 48 | 52 | |
| 49 | 53 | if CFG.Proxy { |
| 50 | mediaUrl = mediaUrl[21:] | |
| 51 | dot := strings.Index(mediaUrl, ".") | |
| 54 | mediaURL = mediaURL[21:] | |
| 55 | dot := strings.Index(mediaURL, ".") | |
| 52 | 56 | a.main.Writer.Header().Del("Content-Type") |
| 53 | a.main.DownloadAndSendMedia(mediaUrl[:dot], mediaUrl[dot+11:]) | |
| 57 | a.main.DownloadAndSendMedia(mediaURL[:dot], mediaURL[dot+11:]) | |
| 54 | 58 | } else { |
| 55 | a.main.Writer.Header().Add("Location", mediaUrl) | |
| 59 | a.main.Writer.Header().Add("Location", mediaURL) | |
| 56 | 60 | a.main.Writer.WriteHeader(302) |
| 57 | 61 | } |
| 58 | 62 | } |
| 59 | 63 | |
| 60 | // TODO: add filters | |
| 64 | // Random responds with a random artwork's media, retrying a bounded number of | |
| 65 | // times when a search comes back empty or NSFW-filtered. | |
| 66 | // | |
| 67 | // TODO: add filters. | |
| 61 | 68 | func (a API) Random() { |
| 62 | 69 | // Bounded retries: the loop used to be unbounded, and the DeviantArt-error |
| 63 | 70 | // path never incremented attempt, so a single request could spin forever |
| 64 | 71 | // hammering the API (and get this instance's egress IP banned). |
| 65 | 72 | const maxAttempts = 3 |
| 66 | 73 | |
| 67 | for attempt := 0; attempt < maxAttempts; attempt++ { | |
| 74 | // math/rand is deliberate: this picks a random artwork to show, which is not | |
| 75 | // a security decision and does not need a cryptographic source. | |
| 76 | for range maxAttempts { | |
| 68 | 77 | // strconv.Itoa, not string(): string(65) is "A", not "65". |
| 69 | s, daErr, err := devianter.PerformSearch(strconv.Itoa(rand.Intn(999)), rand.Intn(30), 'a') | |
| 78 | s, daErr, err := devianter.PerformSearch(strconv.Itoa(rand.Intn(999)), rand.Intn(30), 'a') //nolint:gosec // G404 | |
| 70 | 79 | try(err) |
| 71 | 80 | if daErr.RAW != nil { |
| 72 | 81 | continue |
| @@ -77,7 +86,7 @@ func (a API) Random() { | ||
| 77 | 86 | continue |
| 78 | 87 | } |
| 79 | 88 | |
| 80 | deviation := &s.Results[rand.Intn(len(s.Results))] | |
| 89 | deviation := &s.Results[rand.Intn(len(s.Results))] //nolint:gosec // G404: see above | |
| 81 | 90 | if deviation.NSFW && !CFG.Nsfw { |
| 82 | 91 | continue |
| 83 | 92 | } |
app/cache.go +27 −14
| @@ -1,8 +1,9 @@ | ||
| 1 | // TODO: implement JSON caching and clean up the code | |
| 2 | 1 | package app |
| 3 | 2 | |
| 3 | // TODO: implement JSON caching and clean up the code. | |
| 4 | ||
| 4 | 5 | import ( |
| 5 | "crypto/sha1" | |
| 6 | "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive | |
| 6 | 7 | "encoding/hex" |
| 7 | 8 | "io" |
| 8 | 9 | "os" |
| @@ -20,6 +21,9 @@ type file struct { | ||
| 20 | 21 | var tempFS = make(map[[20]byte]*file) |
| 21 | 22 | var mx = &sync.RWMutex{} |
| 22 | 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. | |
| 23 | 27 | func (s skunkyart) DownloadAndSendMedia(subdomain, path string) { |
| 24 | 28 | var url strings.Builder |
| 25 | 29 | url.WriteString("https://images-wixmp-") |
| @@ -35,20 +39,24 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) { | ||
| 35 | 39 | |
| 36 | 40 | switch { |
| 37 | 41 | case CFG.Cache.Enabled: |
| 38 | fileName := sha1.Sum([]byte(subdomain + path)) | |
| 42 | fileName := sha1.Sum([]byte(subdomain + path)) //nolint:gosec // G401: cache-key hash, not a security primitive | |
| 39 | 43 | filePath := CFG.Cache.Path + "/" + hex.EncodeToString(fileName[:]) |
| 40 | 44 | |
| 41 | 45 | c := func() { |
| 42 | file, err := os.Open(filePath) | |
| 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 | |
| 43 | 49 | if err != nil { |
| 44 | if dwnld := Download(url.String()); dwnld.Status == 200 && dwnld.Headers["Content-Type"][0][:5] == "image" { | |
| 50 | dwnld := Download(url.String()) | |
| 51 | if dwnld.Status == 200 && strings.HasPrefix(dwnld.Headers.Get("Content-Type"), "image") { | |
| 45 | 52 | response = dwnld.Body |
| 46 | try(os.WriteFile(filePath, response, 0700)) | |
| 53 | try(os.WriteFile(filePath, response, 0600)) | |
| 47 | 54 | } else { |
| 48 | 55 | s.ReturnHTTPError(dwnld.Status) |
| 49 | 56 | return |
| 50 | 57 | } |
| 51 | 58 | } else { |
| 59 | defer func() { try(file.Close()) }() | |
| 52 | 60 | file, e := io.ReadAll(file) |
| 53 | 61 | try(e) |
| 54 | 62 | response = file |
| @@ -104,16 +112,19 @@ func (s skunkyart) DownloadAndSendMedia(subdomain, path string) { | ||
| 104 | 112 | response = []byte("Sorry, butt proxy on this instance are disabled.") |
| 105 | 113 | } |
| 106 | 114 | |
| 107 | s.Writer.Write(response) | |
| 115 | _, _ = s.Writer.Write(response) | |
| 108 | 116 | } |
| 109 | 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. | |
| 110 | 121 | func InitCacheSystem() { |
| 111 | 122 | c := &CFG.Cache |
| 112 | 123 | for { |
| 113 | 124 | dir, err := os.ReadDir(c.Path) |
| 114 | 125 | if err != nil { |
| 115 | 126 | if os.IsNotExist(err) { |
| 116 | os.Mkdir(c.Path, 0700) | |
| 127 | try(os.Mkdir(c.Path, 0700)) | |
| 117 | 128 | continue |
| 118 | 129 | } |
| 119 | 130 | println(err.Error()) |
| @@ -128,11 +139,13 @@ func InitCacheSystem() { | ||
| 128 | 139 | if c.Lifetime != "" { |
| 129 | 140 | now := time.Now().UnixMilli() |
| 130 | 141 | |
| 131 | stat := fileInfo.Sys().(*syscall.Stat_t) | |
| 132 | time := statTime(stat) | |
| 133 | ||
| 134 | if time+lifetimeParsed <= now { | |
| 135 | try(os.RemoveAll(fileName)) | |
| 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 | } | |
| 136 | 149 | } |
| 137 | 150 | } |
| 138 | 151 | |
| @@ -144,7 +157,7 @@ func InitCacheSystem() { | ||
| 144 | 157 | |
| 145 | 158 | if c.MaxSize != 0 && total > c.MaxSize { |
| 146 | 159 | try(os.RemoveAll(c.Path)) |
| 147 | os.Mkdir(c.Path, 0700) | |
| 160 | try(os.Mkdir(c.Path, 0700)) | |
| 148 | 161 | } |
| 149 | 162 | |
| 150 | 163 | time.Sleep(time.Second * time.Duration(c.UpdateInterval)) |
app/cli.go +23 −10
| @@ -9,6 +9,9 @@ import ( | ||
| 9 | 9 | "time" |
| 10 | 10 | ) |
| 11 | 11 | |
| 12 | // ExecuteCommandLineArguments parses argv, applying the flags that override | |
| 13 | // config and running one-shot commands such as --help and --add-instance. Some | |
| 14 | // of those commands exit the process rather than return. | |
| 12 | 15 | func ExecuteCommandLineArguments() { |
| 13 | 16 | var helpmsg = `SkunkyArt v{{.Version}} [{{.Description}}] |
| 14 | 17 | Usage: |
| @@ -31,8 +34,12 @@ Copyright lost+skunk, X11. https://github.com/zerolabsco/skunky-art/releases/tag | ||
| 31 | 34 | case "-h", "--help": |
| 32 | 35 | var buf bytes.Buffer |
| 33 | 36 | t := template.New("help") |
| 34 | t.Parse(helpmsg) | |
| 35 | t.Execute(&buf, &Release) | |
| 37 | tryWithExitStatus(func() error { | |
| 38 | if _, err := t.Parse(helpmsg); err != nil { | |
| 39 | return err | |
| 40 | } | |
| 41 | return t.Execute(&buf, &Release) | |
| 42 | }(), 1) | |
| 36 | 43 | exit(buf.String(), 0) |
| 37 | 44 | case "-a", "--add-instance": |
| 38 | 45 | addInstance() |
| @@ -79,13 +86,19 @@ func addInstance() { | ||
| 79 | 86 | var settingsVar struct { |
| 80 | 87 | Instances []settings `json:"instances"` |
| 81 | 88 | } |
| 82 | instancesJson, err := os.OpenFile("instances.json", os.O_CREATE|os.O_WRONLY, 0644) | |
| 83 | try(err) | |
| 84 | defer instancesJson.Close() | |
| 89 | // 0644: both files are committed to the repository and are meant to be | |
| 90 | // world-readable, so gosec's 0600 default does not apply. | |
| 91 | instancesJSON, err := os.OpenFile("instances.json", os.O_CREATE|os.O_WRONLY, 0644) //nolint:gosec // G302 | |
| 92 | if err != nil { | |
| 93 | exit(err.Error(), 1) | |
| 94 | } | |
| 95 | defer func() { try(instancesJSON.Close()) }() | |
| 85 | 96 | |
| 86 | instancesFile, err := os.OpenFile("INSTANCES.md", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) | |
| 87 | try(err) | |
| 88 | defer instancesFile.Close() | |
| 97 | instancesFile, err := os.OpenFile("INSTANCES.md", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) //nolint:gosec // G302 | |
| 98 | if err != nil { | |
| 99 | exit(err.Error(), 1) | |
| 100 | } | |
| 101 | defer func() { try(instancesFile.Close()) }() | |
| 89 | 102 | |
| 90 | 103 | for { |
| 91 | 104 | if string(instances) == "" { |
| @@ -113,7 +126,7 @@ func addInstance() { | ||
| 113 | 126 | j, err := json.MarshalIndent(&settingsVar, "", " ") |
| 114 | 127 | try(err) |
| 115 | 128 | |
| 116 | instancesJson.Write(j) | |
| 129 | try(func() error { _, err := instancesJSON.Write(j); return err }()) | |
| 117 | 130 | |
| 118 | 131 | settingsVar := &settingsVar.Instances[len(settingsVar.Instances)-1] |
| 119 | 132 | var mdstr bytes.Buffer |
| @@ -157,7 +170,7 @@ func addInstance() { | ||
| 157 | 170 | mdstr.WriteString(settingsVar.Country) |
| 158 | 171 | mdstr.WriteString("|") |
| 159 | 172 | |
| 160 | instancesFile.Write(mdstr.Bytes()) | |
| 173 | try(func() error { _, err := instancesFile.Write(mdstr.Bytes()); return err }()) | |
| 161 | 174 | break |
| 162 | 175 | } |
| 163 | 176 | time.Sleep(500 * time.Millisecond) |
app/httpclient.go +49 −9
| @@ -2,6 +2,7 @@ package app | ||
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | 4 | "net/http" |
| 5 | "net/url" | |
| 5 | 6 | "strings" |
| 6 | 7 | "sync" |
| 7 | 8 | "time" |
| @@ -23,6 +24,10 @@ var ( | ||
| 23 | 24 | daMaxConcurrent = 2 // max simultaneous in-flight DA requests |
| 24 | 25 | ) |
| 25 | 26 | |
| 27 | // downloadTimeout bounds a single outbound fetch end to end, so that a stalled | |
| 28 | // CDN connection cannot pin a request handler open indefinitely. | |
| 29 | const downloadTimeout = 60 * time.Second | |
| 30 | ||
| 26 | 31 | type daThrottle struct { |
| 27 | 32 | base http.RoundTripper |
| 28 | 33 | sem chan struct{} |
| @@ -30,6 +35,8 @@ type daThrottle struct { | ||
| 30 | 35 | last time.Time |
| 31 | 36 | } |
| 32 | 37 | |
| 38 | // RoundTrip applies the rate and concurrency limits to DeviantArt requests and | |
| 39 | // passes everything else straight through to the base transport. | |
| 33 | 40 | func (t *daThrottle) RoundTrip(req *http.Request) (*http.Response, error) { |
| 34 | 41 | // Only throttle DeviantArt's WAF-protected API host; let everything else fly. |
| 35 | 42 | if !strings.Contains(req.URL.Hostname(), "deviantart.com") { |
| @@ -51,18 +58,51 @@ func (t *daThrottle) RoundTrip(req *http.Request) (*http.Response, error) { | ||
| 51 | 58 | return t.base.RoundTrip(req) |
| 52 | 59 | } |
| 53 | 60 | |
| 61 | // baseTransport is the tuned transport installed by InstallDAThrottle, kept so | |
| 62 | // that per-client transports (see ProxiedTransport) inherit the same timeouts | |
| 63 | // instead of silently bypassing them. | |
| 64 | var baseTransport *http.Transport | |
| 65 | ||
| 66 | // tunedTransport clones the current default transport, preserving its Proxy | |
| 67 | // (ProxyFromEnvironment) and connection-pool defaults, and tightens timeouts to | |
| 68 | // bound hung connections. | |
| 69 | func tunedTransport() *http.Transport { | |
| 70 | base, ok := http.DefaultTransport.(*http.Transport) | |
| 71 | if !ok { | |
| 72 | // Already wrapped, or a non-standard transport is installed. Start from a | |
| 73 | // fresh one rather than panicking on a type assertion. | |
| 74 | base = &http.Transport{Proxy: http.ProxyFromEnvironment} | |
| 75 | } | |
| 76 | ||
| 77 | t := base.Clone() | |
| 78 | t.TLSHandshakeTimeout = 10 * time.Second | |
| 79 | t.ResponseHeaderTimeout = 20 * time.Second | |
| 80 | t.ExpectContinueTimeout = 2 * time.Second | |
| 81 | return t | |
| 82 | } | |
| 83 | ||
| 54 | 84 | // InstallDAThrottle wraps http.DefaultTransport with the rate/concurrency limits and |
| 55 | 85 | // timeouts above. Call once at startup, before any DeviantArt request is made. |
| 56 | 86 | func InstallDAThrottle() { |
| 57 | // Clone the default transport so we keep its Proxy (ProxyFromEnvironment) and | |
| 58 | // connection-pool defaults, then tighten timeouts to bound hung connections. | |
| 59 | base := http.DefaultTransport.(*http.Transport).Clone() | |
| 60 | base.TLSHandshakeTimeout = 10 * time.Second | |
| 61 | base.ResponseHeaderTimeout = 20 * time.Second | |
| 62 | base.ExpectContinueTimeout = 2 * time.Second | |
| 87 | baseTransport = tunedTransport() | |
| 88 | http.DefaultTransport = throttled(baseTransport) | |
| 89 | } | |
| 90 | ||
| 91 | // throttled wraps base with the DeviantArt rate and concurrency limits. | |
| 92 | func throttled(base http.RoundTripper) http.RoundTripper { | |
| 93 | return &daThrottle{base: base, sem: make(chan struct{}, daMaxConcurrent)} | |
| 94 | } | |
| 63 | 95 | |
| 64 | http.DefaultTransport = &daThrottle{ | |
| 65 | base: base, | |
| 66 | sem: make(chan struct{}, daMaxConcurrent), | |
| 96 | // ProxiedTransport returns a throttled transport routing through proxy. Downloads | |
| 97 | // configured with download-proxy go through here so they keep the timeouts and | |
| 98 | // limits that InstallDAThrottle installs on the default transport. | |
| 99 | func ProxiedTransport(proxy *url.URL) http.RoundTripper { | |
| 100 | var base *http.Transport | |
| 101 | if baseTransport != nil { | |
| 102 | base = baseTransport.Clone() | |
| 103 | } else { | |
| 104 | base = tunedTransport() | |
| 67 | 105 | } |
| 106 | base.Proxy = http.ProxyURL(proxy) | |
| 107 | return throttled(base) | |
| 68 | 108 | } |
app/router.go +34 −11
| @@ -7,10 +7,15 @@ import ( | ||
| 7 | 7 | "skunkyart/static" |
| 8 | 8 | "strconv" |
| 9 | 9 | "strings" |
| 10 | "time" | |
| 10 | 11 | ) |
| 11 | 12 | |
| 13 | // Host is the scheme and host that generated links are built from. It is set per | |
| 14 | // request from the Host header and X-Forwarded-Proto. | |
| 12 | 15 | var Host string |
| 13 | 16 | |
| 17 | // Router registers the single catch-all handler that dispatches every path, then | |
| 18 | // serves until the process exits. It does not return on success. | |
| 14 | 19 | func Router() { |
| 15 | 20 | parsepath := func(path string) map[int]string { |
| 16 | 21 | if l := len(CFG.URI); len(path) > l { |
| @@ -33,22 +38,30 @@ func Router() { | ||
| 33 | 38 | return parsedpath |
| 34 | 39 | } |
| 35 | 40 | |
| 36 | next := func(path map[int]string, from int) (out string) { | |
| 41 | next := func(path map[int]string, from int) string { | |
| 42 | var out strings.Builder | |
| 37 | 43 | for x, l := from, len(path)-1; x <= l; x++ { |
| 38 | out += path[x] | |
| 44 | out.WriteString(path[x]) | |
| 39 | 45 | if x != l { |
| 40 | out += "/" | |
| 46 | out.WriteString("/") | |
| 41 | 47 | } |
| 42 | 48 | } |
| 43 | return | |
| 49 | return out.String() | |
| 44 | 50 | } |
| 45 | 51 | |
| 46 | 52 | open := func(name string) []byte { |
| 47 | 53 | file, err := static.Templates.Open(name) |
| 48 | try(err) | |
| 49 | fileReaded, err := io.ReadAll(file) | |
| 50 | try(err) | |
| 54 | if err != nil { | |
| 55 | try(err) | |
| 56 | return nil | |
| 57 | } | |
| 58 | defer func() { try(file.Close()) }() | |
| 51 | 59 | |
| 60 | fileReaded, err := io.ReadAll(file) | |
| 61 | if err != nil { | |
| 62 | try(err) | |
| 63 | return nil | |
| 64 | } | |
| 52 | 65 | return fileReaded |
| 53 | 66 | } |
| 54 | 67 | |
| @@ -119,10 +132,10 @@ func Router() { | ||
| 119 | 132 | skunky.Emojitar(path[3]) |
| 120 | 133 | } |
| 121 | 134 | case "stylesheet": |
| 122 | w.Header().Add("content-type", "text/css") | |
| 123 | w.Write(open("css/skunky.css")) | |
| 135 | w.Header().Add("Content-Type", "text/css") | |
| 136 | _, _ = w.Write(open("css/skunky.css")) | |
| 124 | 137 | case "favicon.ico": |
| 125 | w.Write(open("images/logo.png")) | |
| 138 | _, _ = w.Write(open("images/logo.png")) | |
| 126 | 139 | |
| 127 | 140 | // API |
| 128 | 141 | case "api": |
| @@ -145,5 +158,15 @@ func Router() { | ||
| 145 | 158 | http.HandleFunc("/", handle) |
| 146 | 159 | println("SkunkyArt is listening on", CFG.Listen) |
| 147 | 160 | |
| 148 | tryWithExitStatus(http.ListenAndServe(CFG.Listen, nil), 1) | |
| 161 | // Explicit timeouts: the bare http.ListenAndServe has none, so a slow client | |
| 162 | // can hold a connection (and its handler) open indefinitely. WriteTimeout is | |
| 163 | // generous because media proxying streams large files through a handler. | |
| 164 | srv := &http.Server{ | |
| 165 | Addr: CFG.Listen, | |
| 166 | ReadHeaderTimeout: 10 * time.Second, | |
| 167 | ReadTimeout: 30 * time.Second, | |
| 168 | WriteTimeout: 120 * time.Second, | |
| 169 | IdleTimeout: 120 * time.Second, | |
| 170 | } | |
| 171 | tryWithExitStatus(srv.ListenAndServe(), 1) | |
| 149 | 172 | } |