krz/skunky-art

Alternative privacy frontend for DeviantArt.

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

d6789522d3dd076f7c98dafa3e7d42df09252093

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-07-15T07:25:20Z

fix: document exported API, fix naming, and harden Download

The remaining golangci-lint findings. These land together because the
Url -> URL rename spans util.go, parsers.go and wrapper.go, and splitting
it would leave an intermediate commit that does not compile.

Download() carried the most serious bug here: try() only prints an error,
it does not return, so a failed request fell through to
resp.Body.Close() on a nil resp and panicked. Every failure path now
returns the zero Downloaded, and callers check Status. ReturnHTTPError
guards against the resulting status 0, which would otherwise panic
WriteHeader. Requests carry a context with a timeout (noctx), and a
download-proxy now routes through ProxiedTransport so it keeps the DA
throttle and timeouts.

Also:
- doc comments on all 48 exported symbols (revive's exported rule, with
  checkPrivateReceivers, since most of app is exported methods on the
  unexported skunkyart type), plus package docs in new doc.go files so
  both the embed and non-embed builds are covered.
- ST1003 naming: UrlBuilder -> URLBuilder, id_search -> idSearch,
  cache_config -> cacheConfig, TXT_RAW -> TxtRaw, mediaUrl -> mediaURL.
- explicit json tags on structs that are unmarshaled (musttag); the
  hyphenated keys already had tags, the rest relied on case-insensitive
  fallback. Behaviour is unchanged.
- modernization: range-over-int, WaitGroup.Go, stale +build lines,
  interface{} -> any, strings.Builder over string concatenation in a loop.
 app/config.go               |  38 +++++++------
 app/doc.go                  |   7 +++
 app/httpclient_test.go      |  18 +++----
 app/parsers.go              | 110 ++++++++++++++++++++++----------------
 app/stat-darwin.go          |   1 -
 app/util.go                 | 127 +++++++++++++++++++++++++++++++++-----------
 app/wrapper.go              |  32 ++++++-----
 static/doc.go               |   8 +++
 static/templates-noembed.go |  29 ++++++++--
 static/templates.go         |   8 ++-
 10 files changed, 259 insertions(+), 119 deletions(-)

diff --git a/app/config.go b/app/config.go
index acd9b71..3b85527 100755
--- a/app/config.go
+++ b/app/config.go
@@ -11,36 +11,41 @@ import (
 	"github.com/zerolabsco/devianter"
 )
 
+// Release carries the build's version and description, set at link time and
+// shown by --help and the API.
 var Release struct {
 	Version     string
 	Description string
 }
 
-type cache_config struct {
-	Enabled        bool
-	MemCache       bool `json:"memcache"`
-	Path           string
-	MaxSize        int64 `json:"max-size"`
-	Lifetime       string
-	UpdateInterval int64 `json:"update-interval"`
+type cacheConfig struct {
+	Enabled        bool   `json:"enabled"`
+	MemCache       bool   `json:"memcache"`
+	Path           string `json:"path"`
+	MaxSize        int64  `json:"max-size"`
+	Lifetime       string `json:"lifetime"`
+	UpdateInterval int64  `json:"update-interval"`
 }
 
 type config struct {
 	cfg           string
-	Listen        string
-	URI           string `json:"uri"`
-	Cache         cache_config
-	Proxy, Nsfw   bool
-	UserAgent     string `json:"user-agent"`
-	DownloadProxy string `json:"download-proxy"`
-	StaticPath    string `json:"static-path"`
+	Listen        string      `json:"listen"`
+	URI           string      `json:"uri"`
+	Cache         cacheConfig `json:"cache"`
+	Proxy         bool        `json:"proxy"`
+	Nsfw          bool        `json:"nsfw"`
+	UserAgent     string      `json:"user-agent"`
+	DownloadProxy string      `json:"download-proxy"`
+	StaticPath    string      `json:"static-path"`
 }
 
+// CFG is the running instance's configuration, holding the defaults below until
+// ExecuteConfig overwrites them from the config file.
 var CFG = config{
 	cfg:    "config.json",
 	Listen: "127.0.0.1:3003",
 	URI:    "/",
-	Cache: cache_config{
+	Cache: cacheConfig{
 		Enabled:        false,
 		Path:           "cache",
 		UpdateInterval: 1,
@@ -53,6 +58,9 @@ var CFG = config{
 
 var lifetimeParsed int64
 
+// ExecuteConfig loads the config file into CFG, validates it, and starts the
+// cache rotation loop if caching is on. It exits the process on a config that
+// cannot be read or that asks for caching without proxying.
 func ExecuteConfig() {
 	if CFG.cfg != "" {
 		f, err := os.ReadFile(CFG.cfg)
diff --git a/app/doc.go b/app/doc.go
new file mode 100644
index 0000000..83d1018
--- /dev/null
+++ b/app/doc.go
@@ -0,0 +1,7 @@
+// Package app implements SkunkyArt: a JavaScript-free alternative frontend for
+// DeviantArt.
+//
+// It fetches upstream data through the devianter library, renders it into static
+// HTML (or Atom feeds) server-side, and optionally proxies and caches media so
+// that no request from the browser reaches DeviantArt directly.
+package app
diff --git a/app/httpclient_test.go b/app/httpclient_test.go
index 650360e..af7dd7d 100644
--- a/app/httpclient_test.go
+++ b/app/httpclient_test.go
@@ -21,8 +21,8 @@ func (s *stubTransport) RoundTrip(req *http.Request) (*http.Response, error) {
 	return httptest.NewRecorder().Result(), nil
 }
 
-func newTestThrottle(base http.RoundTripper, gap time.Duration, max int) *daThrottle {
-	return &daThrottle{base: base, sem: make(chan struct{}, max)}
+func newTestThrottle(base http.RoundTripper, gap time.Duration, maxConcurrent int) *daThrottle {
+	return &daThrottle{base: base, sem: make(chan struct{}, maxConcurrent)}
 }
 
 // DeviantArt requests must be spaced by at least daMinInterval.
@@ -32,7 +32,7 @@ func TestThrottleRateLimitsDeviantArt(t *testing.T) {
 
 	start := time.Now()
 	const n = 3
-	for i := 0; i < n; i++ {
+	for range n {
 		req, _ := http.NewRequest("GET", "https://www.deviantart.com/_puppy/x", nil)
 		if _, err := tr.RoundTrip(req); err != nil {
 			t.Fatalf("unexpected error: %v", err)
@@ -55,7 +55,7 @@ func TestThrottleSkipsOtherHosts(t *testing.T) {
 	tr := newTestThrottle(stub, daMinInterval, daMaxConcurrent)
 
 	start := time.Now()
-	for i := 0; i < 5; i++ {
+	for range 5 {
 		req, _ := http.NewRequest("GET", "https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/x.jpg", nil)
 		if _, err := tr.RoundTrip(req); err != nil {
 			t.Fatalf("unexpected error: %v", err)
@@ -96,13 +96,11 @@ func TestThrottleCapsConcurrency(t *testing.T) {
 	tr := newTestThrottle(counting, daMinInterval, daMaxConcurrent)
 
 	var wg sync.WaitGroup
-	for i := 0; i < 6; i++ {
-		wg.Add(1)
-		go func() {
-			defer wg.Done()
+	for range 6 {
+		wg.Go(func() {
 			req, _ := http.NewRequest("GET", "https://www.deviantart.com/_puppy/x", nil)
-			tr.RoundTrip(req)
-		}()
+			_, _ = tr.RoundTrip(req)
+		})
 	}
 	wg.Wait()
 
diff --git a/app/parsers.go b/app/parsers.go
index 976c1b5..5f92658 100755
--- a/app/parsers.go
+++ b/app/parsers.go
@@ -9,6 +9,9 @@ import (
 	"golang.org/x/net/html"
 )
 
+// ParseComments renders a comment thread, nesting replies under the comment they
+// answer. It returns a placeholder message rather than failing if the upstream
+// fetch errored.
 func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) string {
 	if daError.RAW != nil {
 		return "Failed to fetch comments :("
@@ -29,9 +32,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error)
 		cmmts.WriteString(`"><p id="`)
 		cmmts.WriteString(strconv.Itoa(x.ID))
 		cmmts.WriteString(`"><img src="`)
-		cmmts.WriteString(UrlBuilder("media", "emojitar", x.User.Username, "?type=a"))
+		cmmts.WriteString(URLBuilder("media", "emojitar", x.User.Username, "?type=a"))
 		cmmts.WriteString(`" width="30px" height="30px"><a href="`)
-		cmmts.WriteString(UrlBuilder("group_user", "?q=", x.User.Username, "&type=a"))
+		cmmts.WriteString(URLBuilder("group_user", "?q=", x.User.Username, "&type=a"))
 		cmmts.WriteString(`"><b`)
 		cmmts.WriteString(` class="`)
 		if x.User.Banned {
@@ -76,6 +79,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error)
 	return cmmts.String()
 }
 
+// DeviationList renders devs as an HTML grid, or as an Atom feed when the
+// request asked for one and allowAtom permits it. NSFW entries are dropped
+// unless the instance allows them. Passing content adds a navigation bar.
 func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string {
 	if s.Atom && s.Page > 1 {
 		s.ReturnHTTPError(400)
@@ -86,16 +92,16 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
 
 	for i, l := 0, len(devs); i < l; i++ {
 		data := &devs[i]
-		if preview, fullview := ParseMedia(data.Media, 320), ParseMedia(data.Media); !(data.NSFW && !CFG.Nsfw) {
+		if preview, fullview := ParseMedia(data.Media, 320), ParseMedia(data.Media); !data.NSFW || CFG.Nsfw {
 			if allowAtom && s.Atom {
-				s.Writer.Header().Add("Content-type", "application/atom+xml")
+				s.Writer.Header().Add("Content-Type", "application/atom+xml")
 				id := strconv.Itoa(data.ID)
 				listContent.WriteString(`<entry><author><name>`)
 				listContent.WriteString(data.Author.Username)
 				listContent.WriteString(`</name></author><title>`)
 				listContent.WriteString(data.Title)
 				listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`)
-				listContent.WriteString(UrlBuilder("post", data.Author.Username, "atom-"+id))
+				listContent.WriteString(URLBuilder("post", data.Author.Username, "atom-"+id))
 				listContent.WriteString(`"/><id>`)
 				listContent.WriteString(id)
 				listContent.WriteString(`</id><published>`)
@@ -106,7 +112,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
 				listContent.WriteString(`</media:title><media:thumbinal url="`)
 				listContent.WriteString(preview)
 				listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`)
-				listContent.WriteString(ConvertDeviantArtUrlToSkunkyArt(data.Url))
+				listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(data.Url))
 				listContent.WriteString(`"><img src="`)
 				listContent.WriteString(fullview)
 				listContent.WriteString(`"/></a><p>`)
@@ -124,7 +130,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
 					listContent.WriteString(`<h1>[ TEXT ]</h1>`)
 				}
 				listContent.WriteString(`<br><a href="`)
-				listContent.WriteString(ConvertDeviantArtUrlToSkunkyArt(data.Url))
+				listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(data.Url))
 				listContent.WriteString(`">`)
 				listContent.WriteString(data.Author.Username)
 				listContent.WriteString(" - ")
@@ -149,11 +155,12 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
 		list.WriteString(`<?xml version="1.0" encoding="UTF-8"?><feed xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">`)
 
 		list.WriteString(`<title>`)
-		if s.Type == 0 {
+		switch {
+		case s.Type == 0:
 			list.WriteString("Daily Deviations")
-		} else if s.Type == 'g' && len(devs) != 0 {
+		case s.Type == 'g' && len(devs) != 0:
 			list.WriteString(devs[0].Author.Username)
-		} else {
+		default:
 			list.WriteString("SkunkyArt")
 		}
 		list.WriteString(`</title>`)
@@ -181,19 +188,26 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
 }
 
 /* DESCRIPTION/COMMENT PARSER */
+
+// text is one styled run within a description: the rendered HTML, the raw source
+// it came from, and the offsets it spans in the original block.
 type text struct {
-	TXT     string
-	TXT_RAW string
-	From    int
-	To      int
+	Txt    string
+	TxtRaw string
+	From   int
+	To     int
 }
 
-// TODO: rewrite this whole mess
+// ParseDescription renders a DeviantArt description into HTML, handling both the
+// Draft.js-style JSON payload and the plain HTML markup DeviantArt returns, and
+// rewriting embedded links and artwork references to point at this instance.
+//
+// TODO: rewrite this whole mess.
 func ParseDescription(dscr devianter.Text) string {
 	var parsedDescription strings.Builder
 	TagBuilder := func(content string, tags ...string) string {
 		l := len(tags)
-		for x := 0; x < l; x++ {
+		for x := range l {
 			var htm strings.Builder
 			htm.WriteString("<")
 			htm.WriteString(tags[x])
@@ -208,7 +222,7 @@ func ParseDescription(dscr devianter.Text) string {
 		}
 		return content
 	}
-	DeleteTrackingFromUrl := func(url string) string {
+	DeleteTrackingFromURL := func(url string) string {
 		if len(url) > 42 && url[:42] == "https://www.deviantart.com/users/outgoing?" {
 			url = url[42:]
 		}
@@ -220,30 +234,34 @@ func ParseDescription(dscr devianter.Text) string {
 		description[dl-1] == '}' {
 		var descr struct {
 			Blocks []struct {
-				Text, Type        string
+				Text              string `json:"text"`
+				Type              string `json:"type"`
 				InlineStyleRanges []struct {
-					Offset, Length int
-					Style          string
-				}
+					Offset int    `json:"offset"`
+					Length int    `json:"length"`
+					Style  string `json:"style"`
+				} `json:"inlineStyleRanges"`
 				EntityRanges []struct {
-					Offset, Length int
-					Key            int
-				}
+					Offset int `json:"offset"`
+					Length int `json:"length"`
+					Key    int `json:"key"`
+				} `json:"entityRanges"`
 				Data struct {
-					TextAlignment string
-				}
-			}
+					TextAlignment string `json:"textAlignment"`
+				} `json:"data"`
+			} `json:"blocks"`
 			EntityMap map[string]struct {
-				Type string
+				Type string `json:"type"`
 				Data struct {
-					Url    string
+					URL    string `json:"url"`
 					Config struct {
-						Aligment string
-						Width    int
-					}
-					Data devianter.Deviation
-				}
-			}
+						// "aligment" is DeviantArt's own spelling; do not correct it.
+						Aligment string `json:"aligment"`
+						Width    int    `json:"width"`
+					} `json:"config"`
+					Data devianter.Deviation `json:"data"`
+				} `json:"data"`
+			} `json:"entityMap"`
 		}
 		e := json.Unmarshal([]byte(description), &descr)
 		try(e)
@@ -252,8 +270,8 @@ func ParseDescription(dscr devianter.Text) string {
 		urls := make(map[int]string)
 		for n, x := range descr.EntityMap {
 			num, _ := strconv.Atoi(n)
-			if x.Data.Url != "" {
-				urls[num] = DeleteTrackingFromUrl(x.Data.Url)
+			if x.Data.URL != "" {
+				urls[num] = DeleteTrackingFromURL(x.Data.URL)
 			}
 			entities[num] = x.Data.Data
 		}
@@ -278,10 +296,10 @@ func ParseDescription(dscr devianter.Text) string {
 					FT := Styles.From * Styles.To
 					tags[FT] = append(tags[FT], rngs.Style)
 				}
-				for n := 0; n < len(Styles); n++ {
+				for n := range Styles {
 					Styles := &Styles[n]
-					Styles.TXT_RAW = x.Text[Styles.From:Styles.To]
-					Styles.TXT = TagBuilder(Styles.TXT_RAW, tags[Styles.From*Styles.To]...)
+					Styles.TxtRaw = x.Text[Styles.From:Styles.To]
+					Styles.Txt = TagBuilder(Styles.TxtRaw, tags[Styles.From*Styles.To]...)
 				}
 			}
 
@@ -290,7 +308,7 @@ func ParseDescription(dscr devianter.Text) string {
 				if len(x.EntityRanges) != 0 {
 					d := entities[x.EntityRanges[0].Key]
 					parsedDescription.WriteString(`<a href="`)
-					parsedDescription.WriteString(ConvertDeviantArtUrlToSkunkyArt(d.Url))
+					parsedDescription.WriteString(ConvertDeviantArtURLToSkunkyArt(d.Url))
 					parsedDescription.WriteString(`"><img width="50%" src="`)
 					parsedDescription.WriteString(ParseMedia(d.Media))
 					parsedDescription.WriteString(`" title="`)
@@ -314,10 +332,10 @@ func ParseDescription(dscr devianter.Text) string {
 							parsedDescription.WriteString(`<a target="_blank" href="`)
 							parsedDescription.WriteString(urls[ra.Key])
 							parsedDescription.WriteString(`">`)
-							parsedDescription.WriteString(r.TXT)
+							parsedDescription.WriteString(r.Txt)
 							parsedDescription.WriteString(`</a>`)
 						} else if l > n+1 {
-							parsedDescription.WriteString(r.TXT)
+							parsedDescription.WriteString(r.Txt)
 						}
 						parsedDescription.WriteString(TagBuilder(tag, x.Text[r.To:]))
 					}
@@ -332,13 +350,15 @@ func ParseDescription(dscr devianter.Text) string {
 			switch tt.Next() {
 			case html.ErrorToken:
 				return parsedDescription.String()
+			case html.CommentToken, html.DoctypeToken:
+				// No renderable content; skip.
 			case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken:
 				token := tt.Token()
 				switch token.Data {
 				case "a":
 					for _, a := range token.Attr {
 						if a.Key == "href" {
-							url := DeleteTrackingFromUrl(a.Val)
+							url := DeleteTrackingFromURL(a.Val)
 							parsedDescription.WriteString(`<a target="_blank" href="`)
 							parsedDescription.WriteString(url)
 							parsedDescription.WriteString(`">`)
@@ -352,7 +372,7 @@ func ParseDescription(dscr devianter.Text) string {
 						switch a.Key {
 						case "src":
 							if len(a.Val) > 9 && a.Val[8:9] == "e" {
-								uri = UrlBuilder("media", "emojitar", a.Val[37:len(a.Val)-4], "?type=e")
+								uri = URLBuilder("media", "emojitar", a.Val[37:len(a.Val)-4], "?type=e")
 							}
 						case "title":
 							title = a.Val
diff --git a/app/stat-darwin.go b/app/stat-darwin.go
index f3e05bc..25c550a 100644
--- a/app/stat-darwin.go
+++ b/app/stat-darwin.go
@@ -1,5 +1,4 @@
 //go:build darwin
-// +build darwin
 
 package app
 
diff --git a/app/util.go b/app/util.go
index 1eecc13..8f0cba4 100755
--- a/app/util.go
+++ b/app/util.go
@@ -1,7 +1,9 @@
 package app
 
 import (
+	"context"
 	"encoding/json"
+	"fmt"
 	"io"
 	"net/http"
 	"net/url"
@@ -17,7 +19,12 @@ import (
 )
 
 /* INTERNAL */
-var wr = io.WriteString
+
+// wr writes s to w. A write error here means the client went away mid-response,
+// which a handler cannot act on, so it is deliberately discarded.
+func wr(w io.Writer, s string) {
+	_, _ = io.WriteString(w, s)
+}
 
 func exit(msg string, code int) {
 	println(msg)
@@ -34,15 +41,22 @@ func tryWithExitStatus(err error, code int) {
 	}
 }
 
+// restore swallows a panic in the calling goroutine so that one bad parse cannot
+// take the whole process down. The panic is logged rather than dropped silently.
 func restore() {
 	if r := recover(); r != nil {
-		recover()
+		println("recovered from panic:", fmt.Sprint(r))
 	}
 }
 
 var instances []byte
+
+// About is the instance list and settings shown in the frontend, refreshed by
+// RefreshInstances.
 var About instanceAbout
 
+// RefreshInstances re-fetches the published instance list every hour, forever.
+// Run it in its own goroutine; fetch failures are logged and retried next cycle.
 func RefreshInstances() {
 	for {
 		func() {
@@ -54,11 +68,11 @@ func RefreshInstances() {
 	}
 }
 
-// some crap for frontend
+// instanceAbout is the instance metadata exposed to the frontend and the API.
 type instanceAbout struct {
-	Proxy     bool
-	Nsfw      bool
-	Instances []settings
+	Proxy     bool       `json:"proxy"`
+	Nsfw      bool       `json:"nsfw"`
+	Instances []settings `json:"instances"`
 }
 
 type skunkyart struct {
@@ -118,6 +132,8 @@ type skunkyart struct {
 	}
 }
 
+// ExecuteTemplate renders the named template from dir with data, responding 500
+// if the template cannot be parsed.
 func (s skunkyart) ExecuteTemplate(file, dir string, data any) {
 	var buf strings.Builder
 	tmp := template.New(file)
@@ -131,26 +147,30 @@ func (s skunkyart) ExecuteTemplate(file, dir string, data any) {
 	wr(s.Writer, buf.String())
 }
 
-func UrlBuilder(strs ...string) string {
+// URLBuilder joins strs into an absolute instance URL, prefixing the current
+// Host and configured URI and inserting slashes between path segments but not
+// before query separators.
+func URLBuilder(strs ...string) string {
 	var str strings.Builder
 	l := len(strs)
 	str.WriteString(Host)
 	str.WriteString(CFG.URI)
 	for n, x := range strs {
 		str.WriteString(x)
-		if n := n + 1; n < l && len(strs[n]) != 0 && !(strs[n][0] == '?' || strs[n][0] == '&') && !(x[0] == '?' || x[0] == '&') {
+		if n := n + 1; n < l && len(strs[n]) != 0 && (strs[n][0] != '?' && strs[n][0] != '&') && (x[0] != '?' && x[0] != '&') {
 			str.WriteString("/")
 		}
 	}
 	return str.String()
 }
 
+// Error responds 502 with the error DeviantArt reported upstream.
 func (s skunkyart) Error(dAerr devianter.Error) {
 	s.Writer.WriteHeader(502)
 
 	var msg strings.Builder
 	msg.WriteString(`<html><link rel="stylesheet" href="`)
-	msg.WriteString(UrlBuilder("stylesheet"))
+	msg.WriteString(URLBuilder("stylesheet"))
 	msg.WriteString(`" /><h3>DeviantArt error — '`)
 	msg.WriteString(dAerr.Error)
 	msg.WriteString("'</h3></html>")
@@ -158,12 +178,18 @@ func (s skunkyart) Error(dAerr devianter.Error) {
 	wr(s.Writer, msg.String())
 }
 
+// ReturnHTTPError responds with a styled error page for the given status.
 func (s skunkyart) ReturnHTTPError(status int) {
+	// A failed upstream fetch reports status 0, and WriteHeader panics on any
+	// code outside 1xx-5xx. Treat anything unusable as a gateway failure.
+	if status < 100 || status > 599 {
+		status = http.StatusBadGateway
+	}
 	s.Writer.WriteHeader(status)
 
 	var msg strings.Builder
 	msg.WriteString(`<html><link rel="stylesheet" href="`)
-	msg.WriteString(UrlBuilder("stylesheet"))
+	msg.WriteString(URLBuilder("stylesheet"))
 	msg.WriteString(`" /><h1>`)
 	msg.WriteString(strconv.Itoa(status))
 	msg.WriteString(" - ")
@@ -173,6 +199,7 @@ func (s skunkyart) ReturnHTTPError(status int) {
 	wr(s.Writer, msg.String())
 }
 
+// SetFilename sets the Content-Disposition filename for the response.
 func (s skunkyart) SetFilename(name string) {
 	var filename strings.Builder
 	filename.WriteString(`filename="`)
@@ -181,29 +208,50 @@ func (s skunkyart) SetFilename(name string) {
 	s.Writer.Header().Add("Content-Disposition", filename.String())
 }
 
+// Downloaded is the result of a Download. A Status of 0 means the request never
+// completed, in which case Body and Headers are empty.
 type Downloaded struct {
 	Headers http.Header
 	Status  int
 	Body    []byte
 }
 
+// Download fetches urlString with the configured User-Agent, routing through
+// download-proxy when one is set. Every failure path returns the zero
+// Downloaded, so callers must check Status before trusting Body or Headers.
 func Download(urlString string) (d Downloaded) {
 	cli := &http.Client{}
 	if CFG.DownloadProxy != "" {
-		u, e := url.Parse(CFG.DownloadProxy)
-		try(e)
-		cli.Transport = &http.Transport{Proxy: http.ProxyURL(u)}
+		u, err := url.Parse(CFG.DownloadProxy)
+		if err != nil {
+			try(err)
+			return
+		}
+		cli.Transport = ProxiedTransport(u)
 	}
 
-	req, e := http.NewRequest("GET", urlString, nil)
-	try(e)
+	ctx, cancel := context.WithTimeout(context.Background(), downloadTimeout)
+	defer cancel()
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlString, nil)
+	if err != nil {
+		try(err)
+		return
+	}
 	req.Header.Set("User-Agent", CFG.UserAgent)
 
-	resp, e := cli.Do(req)
-	try(e)
-	defer resp.Body.Close()
-	b, e := io.ReadAll(resp.Body)
-	try(e)
+	resp, err := cli.Do(req)
+	if err != nil {
+		try(err)
+		return
+	}
+	defer func() { try(resp.Body.Close()) }()
+
+	b, err := io.ReadAll(resp.Body)
+	if err != nil {
+		try(err)
+		return
+	}
 
 	d.Body = b
 	d.Status = resp.StatusCode
@@ -212,45 +260,56 @@ func Download(urlString string) (d Downloaded) {
 }
 
 /* PARSING HELPERS */
+
+// ParseMedia returns the URL to serve for media: a link back through this
+// instance's media proxy when proxying is on, or DeviantArt's own URL when it is
+// off. An optional thumb width selects a thumbnail instead of the full image.
 func ParseMedia(media devianter.Media, thumb ...int) string {
-	mediaUrl, filename := devianter.UrlFromMedia(media, thumb...)
-	if len(mediaUrl) != 0 && CFG.Proxy {
-		mediaUrl = mediaUrl[21:]
-		dot := strings.Index(mediaUrl, ".")
+	mediaURL, filename := devianter.UrlFromMedia(media, thumb...)
+	if len(mediaURL) != 0 && CFG.Proxy {
+		mediaURL = mediaURL[21:]
+		dot := strings.Index(mediaURL, ".")
 		if filename == "" {
 			filename = "image.gif"
 		}
-		return UrlBuilder("media", "file", mediaUrl[:dot], mediaUrl[dot+11:], "&filename=", filename)
+		return URLBuilder("media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename)
 	} else if !CFG.Proxy {
-		return mediaUrl
+		return mediaURL
 	}
 	return ""
 }
 
-func ConvertDeviantArtUrlToSkunkyArt(url string) (output string) {
+// ConvertDeviantArtURLToSkunkyArt rewrites a deviantart.com post link into the
+// equivalent link on this instance. It returns an empty string for URLs it does
+// not handle, including sta.sh links.
+func ConvertDeviantArtURLToSkunkyArt(url string) (output string) {
 	if len(url) > 32 && url[27:32] != "stash" {
 		url = url[27:]
 		firstshash := strings.Index(url, "/")
 		lastshash := firstshash + strings.Index(url[firstshash+1:], "/")
 		if lastshash != -1 {
-			output = UrlBuilder("post", url[:firstshash], url[lastshash+2:])
+			output = URLBuilder("post", url[:firstshash], url[lastshash+2:])
 		}
 	}
 	return
 }
 
+// BuildUserPlate renders the small avatar-and-username block linking to a user's
+// about page.
 func BuildUserPlate(name string) string {
 	var htm strings.Builder
 	htm.WriteString(`<div class="user-plate"><img src="`)
-	htm.WriteString(UrlBuilder("media", "emojitar", name, "?type=a"))
+	htm.WriteString(URLBuilder("media", "emojitar", name, "?type=a"))
 	htm.WriteString(`"><a href="`)
-	htm.WriteString(UrlBuilder("group_user", "?type=about&q=", name))
+	htm.WriteString(URLBuilder("group_user", "?type=about&q=", name))
 	htm.WriteString(`">`)
 	htm.WriteString(name)
 	htm.WriteString(`</a></div>`)
 	return htm.String()
 }
 
+// GetValueOfTag returns the text of the tokenizer's next token, or an empty
+// string if that token is not text.
 func GetValueOfTag(t *html.Tokenizer) string {
 	for tt := t.Next(); ; {
 		if tt == html.TextToken {
@@ -261,13 +320,17 @@ func GetValueOfTag(t *html.Tokenizer) string {
 	}
 }
 
-// page navigation
+// DeviationList describes the pagination state of a list of artworks: how many
+// pages exist, and whether another page follows the current one.
 type DeviationList struct {
 	Pages int
 	More  bool
 }
 
-// FIXME: on some artworks the first page can make the navigation panel disappear entirely.
+// NavBase renders the page navigation bar for a list.
+//
+// FIXME: on some artworks the first page can make the navigation panel disappear
+// entirely.
 func (s skunkyart) NavBase(c DeviationList) string {
 	var list strings.Builder
 
diff --git a/app/wrapper.go b/app/wrapper.go
index 9668ce0..7b4fe89 100755
--- a/app/wrapper.go
+++ b/app/wrapper.go
@@ -10,6 +10,8 @@ import (
 	"golang.org/x/net/html"
 )
 
+// GRUser renders a group or user page: the about tab, the gallery, or favourites,
+// selected by the request's type argument.
 func (s skunkyart) GRUser() {
 	if len(s.Query) < 1 {
 		s.ReturnHTTPError(400)
@@ -70,7 +72,7 @@ func (s skunkyart) GRUser() {
 
 			case "cover_deviation":
 				group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation
-				group.About.BGMeta.Url = ConvertDeviantArtUrlToSkunkyArt(group.About.BGMeta.Url)
+				group.About.BGMeta.Url = ConvertDeviantArtURLToSkunkyArt(group.About.BGMeta.Url)
 				group.About.BG = ParseMedia(group.About.BGMeta.Media)
 			case "group_admins":
 				var htm strings.Builder
@@ -120,9 +122,9 @@ func (s skunkyart) GRUser() {
 						if x.FolderId != -1 && x.Size != 0 {
 							folders.WriteString(`<div class="block folder-item">`)
 
-							if !(x.Thumb.NSFW && !CFG.Nsfw) {
+							if !x.Thumb.NSFW || CFG.Nsfw {
 								folders.WriteString(`<a href="`)
-								folders.WriteString(ConvertDeviantArtUrlToSkunkyArt(x.Thumb.Url))
+								folders.WriteString(ConvertDeviantArtURLToSkunkyArt(x.Thumb.Url))
 								folders.WriteString(`"><img loading="lazy" src="`)
 								folders.WriteString(ParseMedia(x.Thumb.Media))
 								folders.WriteString(`" title="`)
@@ -138,7 +140,7 @@ func (s skunkyart) GRUser() {
 							folders.WriteString("&q=")
 							folders.WriteString(s.Query)
 							folders.WriteString("&type=")
-							folders.WriteString(string(s.Type))
+							folders.WriteRune(s.Type)
 							folders.WriteString(`">`)
 							folders.WriteString(x.Name)
 							folders.WriteString(`</a>`)
@@ -167,10 +169,11 @@ func (s skunkyart) GRUser() {
 	}
 }
 
-// posts
+// Deviation renders a single artwork page, with its description, tags, comments
+// and related work. It responds 403 for NSFW posts on instances that disallow them.
 func (s skunkyart) Deviation(author, postname string) {
-	id_search := regexp.MustCompile("[0-9]+").FindAllString(postname, -1)
-	if len(id_search) < 1 {
+	idSearch := regexp.MustCompile("[0-9]+").FindAllString(postname, -1)
+	if len(idSearch) < 1 {
 		s.ReturnHTTPError(400)
 		return
 	}
@@ -178,7 +181,7 @@ func (s skunkyart) Deviation(author, postname string) {
 	var err devianter.Error
 	post := &s.Templates.Deviation
 
-	id := id_search[len(id_search)-1]
+	id := idSearch[len(idSearch)-1]
 	post.Post, err = devianter.GetDeviation(id, author)
 	if err.RAW != nil {
 		s.Error(err)
@@ -188,7 +191,7 @@ func (s skunkyart) Deviation(author, postname string) {
 	if post.Post.Deviation.NSFW && !CFG.Nsfw {
 		s.Writer.WriteHeader(403)
 		wr(s.Writer, `<html><link rel="stylesheet" href="`+
-			UrlBuilder("stylesheet")+
+			URLBuilder("stylesheet")+
 			`" /><h1>NSFW content are disabled on this instance.</h1></html>`)
 		return
 	}
@@ -213,7 +216,7 @@ func (s skunkyart) Deviation(author, postname string) {
 	for _, x := range post.Post.Deviation.Extended.Tags {
 		var tag strings.Builder
 		tag.WriteString(` <a href="`)
-		tag.WriteString(UrlBuilder("search", "?q=", x.Name, "&type=tag"))
+		tag.WriteString(URLBuilder("search", "?q=", x.Name, "&type=tag"))
 		tag.WriteString(`">#`)
 		tag.WriteString(x.Name)
 		tag.WriteString("</a>")
@@ -228,6 +231,7 @@ func (s skunkyart) Deviation(author, postname string) {
 	s.ExecuteTemplate("deviantion.htm", "html", &s)
 }
 
+// DD renders the Daily Deviations page, including each themed strip.
 func (s skunkyart) DD() {
 	dd, err := devianter.GetDailyDeviations(s.Page)
 	if err.RAW != nil {
@@ -256,6 +260,8 @@ func (s skunkyart) DD() {
 	}
 }
 
+// Search renders search results for the request's query. Group search is scraped
+// rather than fetched from the API, which DeviantArt does not expose to guests.
 func (s skunkyart) Search() {
 	if s.Query == "" {
 		s.ReturnHTTPError(400)
@@ -305,7 +311,7 @@ func (s skunkyart) Search() {
 
 		if l := len(usernames); l != 0 {
 			ss.List += `<div class="content plates">`
-			for x := 0; x < len(usernames); x++ {
+			for x := range len(usernames) {
 				ss.List += BuildUserPlate(usernames[x])
 			}
 			ss.List += `</div>`
@@ -334,8 +340,10 @@ func (s skunkyart) Search() {
 	s.ExecuteTemplate("search.htm", "html", &s)
 }
 
+// Emojitar proxies a user's avatar or emoji image, selected by the request's
+// type argument.
 func (s skunkyart) Emojitar(name string) {
-	if name == "" || !(s.Type == 'a' || s.Type == 'e') {
+	if name == "" || (s.Type != 'a' && s.Type != 'e') {
 		s.ReturnHTTPError(400)
 		return
 	}
diff --git a/static/doc.go b/static/doc.go
new file mode 100644
index 0000000..a578372
--- /dev/null
+++ b/static/doc.go
@@ -0,0 +1,8 @@
+// Package static provides the HTML templates, stylesheet and images the
+// frontend serves.
+//
+// It has two implementations selected by the 'embed' build tag. With the tag,
+// the assets are compiled into the binary via go:embed. Without it, they are
+// read from the directory named by StaticPath at startup and held in memory,
+// which is what makes editing templates without a rebuild possible.
+package static
diff --git a/static/templates-noembed.go b/static/templates-noembed.go
index c0dfe7f..ee97ca7 100755
--- a/static/templates-noembed.go
+++ b/static/templates-noembed.go
@@ -1,5 +1,4 @@
 //go:build !embed
-// +build !embed
 
 package static
 
@@ -11,6 +10,7 @@ import (
 	"time"
 )
 
+// Templates is the in-memory asset filesystem populated by CopyTemplatesToMemory.
 var Templates FS
 
 type file struct {
@@ -21,8 +21,12 @@ type file struct {
 
 var templateNames = []string{}
 var templates = make(map[string][]file)
+
+// StaticPath is the directory assets are read from at startup.
 var StaticPath string
 
+// CopyTemplatesToMemory reads every asset under StaticPath into memory. It exits
+// the process on failure, since the frontend cannot serve anything without them.
 func CopyTemplatesToMemory() {
 	baseDir, err := os.ReadDir(StaticPath)
 	try(err)
@@ -53,8 +57,11 @@ func CopyTemplatesToMemory() {
 	}
 }
 
+// FS serves the in-memory assets. It implements the subset of fs.FS that
+// template.ParseFS requires.
 type FS struct{}
 
+// Open returns the asset stored at name, or an fs.PathError if there is none.
 func (FS) Open(name string) (fs.File, error) {
 	for i, l := 0, len(templateNames); i < l; i++ {
 		for _, x := range templates[templateNames[i]] {
@@ -69,6 +76,8 @@ func (FS) Open(name string) (fs.File, error) {
 	return nil, &fs.PathError{}
 }
 
+// Glob returns the paths of every asset in the directory named by pattern's
+// first segment, or an fs.PathError if none match.
 func (FS) Glob(pattern string) ([]string, error) {
 	trimmed := strings.Split(pattern, "/")
 	var matches = []string{}
@@ -91,47 +100,60 @@ func try(err error) {
 	}
 }
 
-/* based on https://github.com/psanford/memfs; required for templates.ParseFS to work correctly */
+// fileInfo is a minimal fs.FileInfo. Assets are held in memory and never stat'd
+// for anything but their name, so the remaining fields report fixed values.
+//
+// Based on https://github.com/psanford/memfs; required for templates.ParseFS to
+// work correctly.
 type fileInfo struct {
 	name string
 }
 
+// Name returns the asset's path.
 func (fi fileInfo) Name() string {
 	return fi.name
 }
 
+// Size reports a fixed placeholder size; callers here never use it.
 func (fi fileInfo) Size() int64 {
 	return 4096
 }
 
+// Mode reports no mode bits: in-memory assets have no filesystem permissions.
 func (fileInfo) Mode() fs.FileMode {
 	return 0
 }
 
+// ModTime reports the zero time, as in-memory assets are never modified.
 func (fileInfo) ModTime() time.Time {
 	return time.Time{}
 }
 
+// IsDir always reports false: only files are stored, never directories.
 func (fileInfo) IsDir() bool {
 	return false
 }
 
-func (fileInfo) Sys() interface{} {
+// Sys returns nil, as there is no underlying data source.
+func (fileInfo) Sys() any {
 	return nil
 }
 
+// File is a read-once handle to an in-memory asset.
 type File struct {
 	name    string
 	content *bytes.Buffer
 	closed  bool
 }
 
+// Stat returns the file's fileInfo. It never fails.
 func (f *File) Stat() (fs.FileInfo, error) {
 	return fileInfo{
 		name: f.name,
 	}, nil
 }
 
+// Read consumes the asset's contents, reporting fs.ErrClosed once closed.
 func (f *File) Read(b []byte) (int, error) {
 	if f.closed {
 		return 0, fs.ErrClosed
@@ -139,6 +161,7 @@ func (f *File) Read(b []byte) (int, error) {
 	return f.content.Read(b)
 }
 
+// Close marks the file closed. Closing twice reports fs.ErrClosed.
 func (f *File) Close() error {
 	if f.closed {
 		return fs.ErrClosed
diff --git a/static/templates.go b/static/templates.go
index 28a057a..859f075 100755
--- a/static/templates.go
+++ b/static/templates.go
@@ -1,16 +1,22 @@
 //go:build embed
-// +build embed
 
 package static
 
 import "embed"
 
+// Templates is the asset filesystem compiled into the binary.
+//
 //go:embed *
 var Templates embed.FS
+
+// Enabled reports that assets are embedded in this build.
 var Enabled bool = true
 
+// StaticPath is accepted for parity with the non-embed build, where it names the
+// directory assets are read from. It is ignored here.
 var StaticPath string
 
+// CopyTemplatesToMemory is a no-op in this build: the assets are already embedded.
 func CopyTemplatesToMemory() {
 	_ = StaticPath
 }