summaryrefslogtreecommitdiff
path: root/app/api.go
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-15 02:24:58 -0500
committerChristian Cleberg <[email protected]>2026-07-15 02:24:58 -0500
commit7eb5e5e2230b6fb5b1bed6f0eaa03279aa61555b (patch)
tree85e30ad9bf89809e3f2179123239d52bda81f63c /app/api.go
parent40d405f318a109aad5e7de7ad71fedcc04bb0e86 (diff)
downloadskunky-art-7eb5e5e2230b6fb5b1bed6f0eaa03279aa61555b.tar.gz
skunky-art-7eb5e5e2230b6fb5b1bed6f0eaa03279aa61555b.tar.bz2
skunky-art-7eb5e5e2230b6fb5b1bed6f0eaa03279aa61555b.zip
fix: harden HTTP transport, server timeouts and panic paths
Correctness and security findings surfaced by golangci-lint, plus two latent panics found alongside them. - router: http.ListenAndServe has no timeouts at all (gosec G114), so a slow client could hold a connection and its handler open indefinitely. Replace it with an explicit http.Server carrying read/write/idle timeouts. - httpclient: InstallDAThrottle asserted http.DefaultTransport was a *http.Transport and would panic outright if anything had already wrapped it -- which is precisely what that function does. Check the assertion and fall back to a fresh transport. Expose ProxiedTransport so a configured download-proxy can inherit the same throttle and timeouts instead of silently bypassing them. - cache: the Sys() assertion to *syscall.Stat_t is only valid on unix and would panic elsewhere; skip rotation instead. Indexing Headers["Content-Type"][0] panics when the header is absent; use Headers.Get. Cache files are written 0600 rather than 0700, as they are never executed. - cli, api: check error returns, and exit rather than nil-dereference a file handle that failed to open. SHA-1 and math/rand keep //nolint:gosec with reasons: they are cache-key hashes and random-artwork picks, not security primitives.
Diffstat (limited to 'app/api.go')
-rwxr-xr-xapp/api.go31
1 files changed, 20 insertions, 11 deletions
diff --git a/app/api.go b/app/api.go
index c891c36..59d045f 100755
--- a/app/api.go
+++ b/app/api.go
@@ -9,6 +9,8 @@ import (
"github.com/zerolabsco/devianter"
)
+// API serves the JSON endpoints under /api, backed by the request its main
+// field points at.
type API struct {
main *skunkyart
}
@@ -18,6 +20,7 @@ type info struct {
Settings settingsParams `json:"settings"`
}
+// Info responds with this instance's version and its proxy/NSFW settings.
func (a API) Info() {
json, err := json.Marshal(info{
Version: a.main.Version,
@@ -27,9 +30,10 @@ func (a API) Info() {
},
})
try(err)
- a.main.Writer.Write(json)
+ _, _ = a.main.Writer.Write(json)
}
+// Error responds with a JSON error body and the given HTTP status.
func (a API) Error(description string, status int) {
a.main.Writer.WriteHeader(status)
var response strings.Builder
@@ -40,33 +44,38 @@ func (a API) Error(description string, status int) {
}
func (a API) sendMedia(d *devianter.Deviation) {
- mediaUrl, name := devianter.UrlFromMedia(d.Media)
+ mediaURL, name := devianter.UrlFromMedia(d.Media)
a.main.SetFilename(name)
- if len(mediaUrl) != 0 {
+ if len(mediaURL) != 0 {
return
}
if CFG.Proxy {
- mediaUrl = mediaUrl[21:]
- dot := strings.Index(mediaUrl, ".")
+ mediaURL = mediaURL[21:]
+ dot := strings.Index(mediaURL, ".")
a.main.Writer.Header().Del("Content-Type")
- a.main.DownloadAndSendMedia(mediaUrl[:dot], mediaUrl[dot+11:])
+ a.main.DownloadAndSendMedia(mediaURL[:dot], mediaURL[dot+11:])
} else {
- a.main.Writer.Header().Add("Location", mediaUrl)
+ a.main.Writer.Header().Add("Location", mediaURL)
a.main.Writer.WriteHeader(302)
}
}
-// TODO: add filters
+// Random responds with a random artwork's media, retrying a bounded number of
+// times when a search comes back empty or NSFW-filtered.
+//
+// TODO: add filters.
func (a API) Random() {
// Bounded retries: the loop used to be unbounded, and the DeviantArt-error
// path never incremented attempt, so a single request could spin forever
// hammering the API (and get this instance's egress IP banned).
const maxAttempts = 3
- for attempt := 0; attempt < maxAttempts; attempt++ {
+ // math/rand is deliberate: this picks a random artwork to show, which is not
+ // a security decision and does not need a cryptographic source.
+ for range maxAttempts {
// strconv.Itoa, not string(): string(65) is "A", not "65".
- s, daErr, err := devianter.PerformSearch(strconv.Itoa(rand.Intn(999)), rand.Intn(30), 'a')
+ s, daErr, err := devianter.PerformSearch(strconv.Itoa(rand.Intn(999)), rand.Intn(30), 'a') //nolint:gosec // G404
try(err)
if daErr.RAW != nil {
continue
@@ -77,7 +86,7 @@ func (a API) Random() {
continue
}
- deviation := &s.Results[rand.Intn(len(s.Results))]
+ deviation := &s.Results[rand.Intn(len(s.Results))] //nolint:gosec // G404: see above
if deviation.NSFW && !CFG.Nsfw {
continue
}