Commit d6789522d3

d6789522d3dd076f7c98dafa3e7d42df09252093

parent: 7eb5e5e223

Verified · cmc

cmc <hello@cleberg.net> · 2026-07-15 07:25 UTC

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.

Layout: unified · split

app/config.go +23 −15
@@ -11,36 +11,41 @@ import (
1111 "github.com/zerolabsco/devianter"
1212)
1313
14// Release carries the build's version and description, set at link time and
15// shown by --help and the API.
1416var Release struct {
1517 Version string
1618 Description string
1719}
1820
19type cache_config struct {
20 Enabled bool
21 MemCache bool `json:"memcache"`
22 Path string
23 MaxSize int64 `json:"max-size"`
24 Lifetime string
25 UpdateInterval int64 `json:"update-interval"`
21type cacheConfig struct {
22 Enabled bool `json:"enabled"`
23 MemCache bool `json:"memcache"`
24 Path string `json:"path"`
25 MaxSize int64 `json:"max-size"`
26 Lifetime string `json:"lifetime"`
27 UpdateInterval int64 `json:"update-interval"`
2628}
2729
2830type config struct {
2931 cfg string
30 Listen string
31 URI string `json:"uri"`
32 Cache cache_config
33 Proxy, Nsfw bool
34 UserAgent string `json:"user-agent"`
35 DownloadProxy string `json:"download-proxy"`
36 StaticPath string `json:"static-path"`
32 Listen string `json:"listen"`
33 URI string `json:"uri"`
34 Cache cacheConfig `json:"cache"`
35 Proxy bool `json:"proxy"`
36 Nsfw bool `json:"nsfw"`
37 UserAgent string `json:"user-agent"`
38 DownloadProxy string `json:"download-proxy"`
39 StaticPath string `json:"static-path"`
3740}
3841
42// CFG is the running instance's configuration, holding the defaults below until
43// ExecuteConfig overwrites them from the config file.
3944var CFG = config{
4045 cfg: "config.json",
4146 Listen: "127.0.0.1:3003",
4247 URI: "/",
43 Cache: cache_config{
48 Cache: cacheConfig{
4449 Enabled: false,
4550 Path: "cache",
4651 UpdateInterval: 1,
@@ -53,6 +58,9 @@ var CFG = config{
5358
5459var lifetimeParsed int64
5560
61// ExecuteConfig loads the config file into CFG, validates it, and starts the
62// cache rotation loop if caching is on. It exits the process on a config that
63// cannot be read or that asks for caching without proxying.
5664func ExecuteConfig() {
5765 if CFG.cfg != "" {
5866 f, err := os.ReadFile(CFG.cfg)
app/doc.go added +7
@@ -0,0 +1,7 @@
1// Package app implements SkunkyArt: a JavaScript-free alternative frontend for
2// DeviantArt.
3//
4// It fetches upstream data through the devianter library, renders it into static
5// HTML (or Atom feeds) server-side, and optionally proxies and caches media so
6// that no request from the browser reaches DeviantArt directly.
7package app
app/httpclient_test.go +8 −10
@@ -21,8 +21,8 @@ func (s *stubTransport) RoundTrip(req *http.Request) (*http.Response, error) {
2121 return httptest.NewRecorder().Result(), nil
2222}
2323
24func newTestThrottle(base http.RoundTripper, gap time.Duration, max int) *daThrottle {
25 return &daThrottle{base: base, sem: make(chan struct{}, max)}
24func newTestThrottle(base http.RoundTripper, gap time.Duration, maxConcurrent int) *daThrottle {
25 return &daThrottle{base: base, sem: make(chan struct{}, maxConcurrent)}
2626}
2727
2828// DeviantArt requests must be spaced by at least daMinInterval.
@@ -32,7 +32,7 @@ func TestThrottleRateLimitsDeviantArt(t *testing.T) {
3232
3333 start := time.Now()
3434 const n = 3
35 for i := 0; i < n; i++ {
35 for range n {
3636 req, _ := http.NewRequest("GET", "https://www.deviantart.com/_puppy/x", nil)
3737 if _, err := tr.RoundTrip(req); err != nil {
3838 t.Fatalf("unexpected error: %v", err)
@@ -55,7 +55,7 @@ func TestThrottleSkipsOtherHosts(t *testing.T) {
5555 tr := newTestThrottle(stub, daMinInterval, daMaxConcurrent)
5656
5757 start := time.Now()
58 for i := 0; i < 5; i++ {
58 for range 5 {
5959 req, _ := http.NewRequest("GET", "https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/x.jpg", nil)
6060 if _, err := tr.RoundTrip(req); err != nil {
6161 t.Fatalf("unexpected error: %v", err)
@@ -96,13 +96,11 @@ func TestThrottleCapsConcurrency(t *testing.T) {
9696 tr := newTestThrottle(counting, daMinInterval, daMaxConcurrent)
9797
9898 var wg sync.WaitGroup
99 for i := 0; i < 6; i++ {
100 wg.Add(1)
101 go func() {
102 defer wg.Done()
99 for range 6 {
100 wg.Go(func() {
103101 req, _ := http.NewRequest("GET", "https://www.deviantart.com/_puppy/x", nil)
104 tr.RoundTrip(req)
105 }()
102 _, _ = tr.RoundTrip(req)
103 })
106104 }
107105 wg.Wait()
108106
app/parsers.go +65 −45
@@ -9,6 +9,9 @@ import (
99 "golang.org/x/net/html"
1010)
1111
12// ParseComments renders a comment thread, nesting replies under the comment they
13// answer. It returns a placeholder message rather than failing if the upstream
14// fetch errored.
1215func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) string {
1316 if daError.RAW != nil {
1417 return "Failed to fetch comments :("
@@ -29,9 +32,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error)
2932 cmmts.WriteString(`"><p id="`)
3033 cmmts.WriteString(strconv.Itoa(x.ID))
3134 cmmts.WriteString(`"><img src="`)
32 cmmts.WriteString(UrlBuilder("media", "emojitar", x.User.Username, "?type=a"))
35 cmmts.WriteString(URLBuilder("media", "emojitar", x.User.Username, "?type=a"))
3336 cmmts.WriteString(`" width="30px" height="30px"><a href="`)
34 cmmts.WriteString(UrlBuilder("group_user", "?q=", x.User.Username, "&type=a"))
37 cmmts.WriteString(URLBuilder("group_user", "?q=", x.User.Username, "&type=a"))
3538 cmmts.WriteString(`"><b`)
3639 cmmts.WriteString(` class="`)
3740 if x.User.Banned {
@@ -76,6 +79,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error)
7679 return cmmts.String()
7780}
7881
82// DeviationList renders devs as an HTML grid, or as an Atom feed when the
83// request asked for one and allowAtom permits it. NSFW entries are dropped
84// unless the instance allows them. Passing content adds a navigation bar.
7985func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string {
8086 if s.Atom && s.Page > 1 {
8187 s.ReturnHTTPError(400)
@@ -86,16 +92,16 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
8692
8793 for i, l := 0, len(devs); i < l; i++ {
8894 data := &devs[i]
89 if preview, fullview := ParseMedia(data.Media, 320), ParseMedia(data.Media); !(data.NSFW && !CFG.Nsfw) {
95 if preview, fullview := ParseMedia(data.Media, 320), ParseMedia(data.Media); !data.NSFW || CFG.Nsfw {
9096 if allowAtom && s.Atom {
91 s.Writer.Header().Add("Content-type", "application/atom+xml")
97 s.Writer.Header().Add("Content-Type", "application/atom+xml")
9298 id := strconv.Itoa(data.ID)
9399 listContent.WriteString(`<entry><author><name>`)
94100 listContent.WriteString(data.Author.Username)
95101 listContent.WriteString(`</name></author><title>`)
96102 listContent.WriteString(data.Title)
97103 listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`)
98 listContent.WriteString(UrlBuilder("post", data.Author.Username, "atom-"+id))
104 listContent.WriteString(URLBuilder("post", data.Author.Username, "atom-"+id))
99105 listContent.WriteString(`"/><id>`)
100106 listContent.WriteString(id)
101107 listContent.WriteString(`</id><published>`)
@@ -106,7 +112,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
106112 listContent.WriteString(`</media:title><media:thumbinal url="`)
107113 listContent.WriteString(preview)
108114 listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`)
109 listContent.WriteString(ConvertDeviantArtUrlToSkunkyArt(data.Url))
115 listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(data.Url))
110116 listContent.WriteString(`"><img src="`)
111117 listContent.WriteString(fullview)
112118 listContent.WriteString(`"/></a><p>`)
@@ -124,7 +130,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
124130 listContent.WriteString(`<h1>[ TEXT ]</h1>`)
125131 }
126132 listContent.WriteString(`<br><a href="`)
127 listContent.WriteString(ConvertDeviantArtUrlToSkunkyArt(data.Url))
133 listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(data.Url))
128134 listContent.WriteString(`">`)
129135 listContent.WriteString(data.Author.Username)
130136 listContent.WriteString(" - ")
@@ -149,11 +155,12 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
149155 list.WriteString(`<?xml version="1.0" encoding="UTF-8"?><feed xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">`)
150156
151157 list.WriteString(`<title>`)
152 if s.Type == 0 {
158 switch {
159 case s.Type == 0:
153160 list.WriteString("Daily Deviations")
154 } else if s.Type == 'g' && len(devs) != 0 {
161 case s.Type == 'g' && len(devs) != 0:
155162 list.WriteString(devs[0].Author.Username)
156 } else {
163 default:
157164 list.WriteString("SkunkyArt")
158165 }
159166 list.WriteString(`</title>`)
@@ -181,19 +188,26 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
181188}
182189
183190/* DESCRIPTION/COMMENT PARSER */
191
192// text is one styled run within a description: the rendered HTML, the raw source
193// it came from, and the offsets it spans in the original block.
184194type text struct {
185 TXT string
186 TXT_RAW string
187 From int
188 To int
195 Txt string
196 TxtRaw string
197 From int
198 To int
189199}
190200
191// TODO: rewrite this whole mess
201// ParseDescription renders a DeviantArt description into HTML, handling both the
202// Draft.js-style JSON payload and the plain HTML markup DeviantArt returns, and
203// rewriting embedded links and artwork references to point at this instance.
204//
205// TODO: rewrite this whole mess.
192206func ParseDescription(dscr devianter.Text) string {
193207 var parsedDescription strings.Builder
194208 TagBuilder := func(content string, tags ...string) string {
195209 l := len(tags)
196 for x := 0; x < l; x++ {
210 for x := range l {
197211 var htm strings.Builder
198212 htm.WriteString("<")
199213 htm.WriteString(tags[x])
@@ -208,7 +222,7 @@ func ParseDescription(dscr devianter.Text) string {
208222 }
209223 return content
210224 }
211 DeleteTrackingFromUrl := func(url string) string {
225 DeleteTrackingFromURL := func(url string) string {
212226 if len(url) > 42 && url[:42] == "https://www.deviantart.com/users/outgoing?" {
213227 url = url[42:]
214228 }
@@ -220,30 +234,34 @@ func ParseDescription(dscr devianter.Text) string {
220234 description[dl-1] == '}' {
221235 var descr struct {
222236 Blocks []struct {
223 Text, Type string
237 Text string `json:"text"`
238 Type string `json:"type"`
224239 InlineStyleRanges []struct {
225 Offset, Length int
226 Style string
227 }
240 Offset int `json:"offset"`
241 Length int `json:"length"`
242 Style string `json:"style"`
243 } `json:"inlineStyleRanges"`
228244 EntityRanges []struct {
229 Offset, Length int
230 Key int
231 }
245 Offset int `json:"offset"`
246 Length int `json:"length"`
247 Key int `json:"key"`
248 } `json:"entityRanges"`
232249 Data struct {
233 TextAlignment string
234 }
235 }
250 TextAlignment string `json:"textAlignment"`
251 } `json:"data"`
252 } `json:"blocks"`
236253 EntityMap map[string]struct {
237 Type string
254 Type string `json:"type"`
238255 Data struct {
239 Url string
256 URL string `json:"url"`
240257 Config struct {
241 Aligment string
242 Width int
243 }
244 Data devianter.Deviation
245 }
246 }
258 // "aligment" is DeviantArt's own spelling; do not correct it.
259 Aligment string `json:"aligment"`
260 Width int `json:"width"`
261 } `json:"config"`
262 Data devianter.Deviation `json:"data"`
263 } `json:"data"`
264 } `json:"entityMap"`
247265 }
248266 e := json.Unmarshal([]byte(description), &descr)
249267 try(e)
@@ -252,8 +270,8 @@ func ParseDescription(dscr devianter.Text) string {
252270 urls := make(map[int]string)
253271 for n, x := range descr.EntityMap {
254272 num, _ := strconv.Atoi(n)
255 if x.Data.Url != "" {
256 urls[num] = DeleteTrackingFromUrl(x.Data.Url)
273 if x.Data.URL != "" {
274 urls[num] = DeleteTrackingFromURL(x.Data.URL)
257275 }
258276 entities[num] = x.Data.Data
259277 }
@@ -278,10 +296,10 @@ func ParseDescription(dscr devianter.Text) string {
278296 FT := Styles.From * Styles.To
279297 tags[FT] = append(tags[FT], rngs.Style)
280298 }
281 for n := 0; n < len(Styles); n++ {
299 for n := range Styles {
282300 Styles := &Styles[n]
283 Styles.TXT_RAW = x.Text[Styles.From:Styles.To]
284 Styles.TXT = TagBuilder(Styles.TXT_RAW, tags[Styles.From*Styles.To]...)
301 Styles.TxtRaw = x.Text[Styles.From:Styles.To]
302 Styles.Txt = TagBuilder(Styles.TxtRaw, tags[Styles.From*Styles.To]...)
285303 }
286304 }
287305
@@ -290,7 +308,7 @@ func ParseDescription(dscr devianter.Text) string {
290308 if len(x.EntityRanges) != 0 {
291309 d := entities[x.EntityRanges[0].Key]
292310 parsedDescription.WriteString(`<a href="`)
293 parsedDescription.WriteString(ConvertDeviantArtUrlToSkunkyArt(d.Url))
311 parsedDescription.WriteString(ConvertDeviantArtURLToSkunkyArt(d.Url))
294312 parsedDescription.WriteString(`"><img width="50%" src="`)
295313 parsedDescription.WriteString(ParseMedia(d.Media))
296314 parsedDescription.WriteString(`" title="`)
@@ -314,10 +332,10 @@ func ParseDescription(dscr devianter.Text) string {
314332 parsedDescription.WriteString(`<a target="_blank" href="`)
315333 parsedDescription.WriteString(urls[ra.Key])
316334 parsedDescription.WriteString(`">`)
317 parsedDescription.WriteString(r.TXT)
335 parsedDescription.WriteString(r.Txt)
318336 parsedDescription.WriteString(`</a>`)
319337 } else if l > n+1 {
320 parsedDescription.WriteString(r.TXT)
338 parsedDescription.WriteString(r.Txt)
321339 }
322340 parsedDescription.WriteString(TagBuilder(tag, x.Text[r.To:]))
323341 }
@@ -332,13 +350,15 @@ func ParseDescription(dscr devianter.Text) string {
332350 switch tt.Next() {
333351 case html.ErrorToken:
334352 return parsedDescription.String()
353 case html.CommentToken, html.DoctypeToken:
354 // No renderable content; skip.
335355 case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken:
336356 token := tt.Token()
337357 switch token.Data {
338358 case "a":
339359 for _, a := range token.Attr {
340360 if a.Key == "href" {
341 url := DeleteTrackingFromUrl(a.Val)
361 url := DeleteTrackingFromURL(a.Val)
342362 parsedDescription.WriteString(`<a target="_blank" href="`)
343363 parsedDescription.WriteString(url)
344364 parsedDescription.WriteString(`">`)
@@ -352,7 +372,7 @@ func ParseDescription(dscr devianter.Text) string {
352372 switch a.Key {
353373 case "src":
354374 if len(a.Val) > 9 && a.Val[8:9] == "e" {
355 uri = UrlBuilder("media", "emojitar", a.Val[37:len(a.Val)-4], "?type=e")
375 uri = URLBuilder("media", "emojitar", a.Val[37:len(a.Val)-4], "?type=e")
356376 }
357377 case "title":
358378 title = a.Val
app/stat-darwin.go −1
@@ -1,5 +1,4 @@
11//go:build darwin
2// +build darwin
32
43package app
54
app/util.go +95 −32
@@ -1,7 +1,9 @@
11package app
22
33import (
4 "context"
45 "encoding/json"
6 "fmt"
57 "io"
68 "net/http"
79 "net/url"
@@ -17,7 +19,12 @@ import (
1719)
1820
1921/* INTERNAL */
20var wr = io.WriteString
22
23// wr writes s to w. A write error here means the client went away mid-response,
24// which a handler cannot act on, so it is deliberately discarded.
25func wr(w io.Writer, s string) {
26 _, _ = io.WriteString(w, s)
27}
2128
2229func exit(msg string, code int) {
2330 println(msg)
@@ -34,15 +41,22 @@ func tryWithExitStatus(err error, code int) {
3441 }
3542}
3643
44// restore swallows a panic in the calling goroutine so that one bad parse cannot
45// take the whole process down. The panic is logged rather than dropped silently.
3746func restore() {
3847 if r := recover(); r != nil {
39 recover()
48 println("recovered from panic:", fmt.Sprint(r))
4049 }
4150}
4251
4352var instances []byte
53
54// About is the instance list and settings shown in the frontend, refreshed by
55// RefreshInstances.
4456var About instanceAbout
4557
58// RefreshInstances re-fetches the published instance list every hour, forever.
59// Run it in its own goroutine; fetch failures are logged and retried next cycle.
4660func RefreshInstances() {
4761 for {
4862 func() {
@@ -54,11 +68,11 @@ func RefreshInstances() {
5468 }
5569}
5670
57// some crap for frontend
71// instanceAbout is the instance metadata exposed to the frontend and the API.
5872type instanceAbout struct {
59 Proxy bool
60 Nsfw bool
61 Instances []settings
73 Proxy bool `json:"proxy"`
74 Nsfw bool `json:"nsfw"`
75 Instances []settings `json:"instances"`
6276}
6377
6478type skunkyart struct {
@@ -118,6 +132,8 @@ type skunkyart struct {
118132 }
119133}
120134
135// ExecuteTemplate renders the named template from dir with data, responding 500
136// if the template cannot be parsed.
121137func (s skunkyart) ExecuteTemplate(file, dir string, data any) {
122138 var buf strings.Builder
123139 tmp := template.New(file)
@@ -131,26 +147,30 @@ func (s skunkyart) ExecuteTemplate(file, dir string, data any) {
131147 wr(s.Writer, buf.String())
132148}
133149
134func UrlBuilder(strs ...string) string {
150// URLBuilder joins strs into an absolute instance URL, prefixing the current
151// Host and configured URI and inserting slashes between path segments but not
152// before query separators.
153func URLBuilder(strs ...string) string {
135154 var str strings.Builder
136155 l := len(strs)
137156 str.WriteString(Host)
138157 str.WriteString(CFG.URI)
139158 for n, x := range strs {
140159 str.WriteString(x)
141 if n := n + 1; n < l && len(strs[n]) != 0 && !(strs[n][0] == '?' || strs[n][0] == '&') && !(x[0] == '?' || x[0] == '&') {
160 if n := n + 1; n < l && len(strs[n]) != 0 && (strs[n][0] != '?' && strs[n][0] != '&') && (x[0] != '?' && x[0] != '&') {
142161 str.WriteString("/")
143162 }
144163 }
145164 return str.String()
146165}
147166
167// Error responds 502 with the error DeviantArt reported upstream.
148168func (s skunkyart) Error(dAerr devianter.Error) {
149169 s.Writer.WriteHeader(502)
150170
151171 var msg strings.Builder
152172 msg.WriteString(`<html><link rel="stylesheet" href="`)
153 msg.WriteString(UrlBuilder("stylesheet"))
173 msg.WriteString(URLBuilder("stylesheet"))
154174 msg.WriteString(`" /><h3>DeviantArt error — '`)
155175 msg.WriteString(dAerr.Error)
156176 msg.WriteString("'</h3></html>")
@@ -158,12 +178,18 @@ func (s skunkyart) Error(dAerr devianter.Error) {
158178 wr(s.Writer, msg.String())
159179}
160180
181// ReturnHTTPError responds with a styled error page for the given status.
161182func (s skunkyart) ReturnHTTPError(status int) {
183 // A failed upstream fetch reports status 0, and WriteHeader panics on any
184 // code outside 1xx-5xx. Treat anything unusable as a gateway failure.
185 if status < 100 || status > 599 {
186 status = http.StatusBadGateway
187 }
162188 s.Writer.WriteHeader(status)
163189
164190 var msg strings.Builder
165191 msg.WriteString(`<html><link rel="stylesheet" href="`)
166 msg.WriteString(UrlBuilder("stylesheet"))
192 msg.WriteString(URLBuilder("stylesheet"))
167193 msg.WriteString(`" /><h1>`)
168194 msg.WriteString(strconv.Itoa(status))
169195 msg.WriteString(" - ")
@@ -173,6 +199,7 @@ func (s skunkyart) ReturnHTTPError(status int) {
173199 wr(s.Writer, msg.String())
174200}
175201
202// SetFilename sets the Content-Disposition filename for the response.
176203func (s skunkyart) SetFilename(name string) {
177204 var filename strings.Builder
178205 filename.WriteString(`filename="`)
@@ -181,29 +208,50 @@ func (s skunkyart) SetFilename(name string) {
181208 s.Writer.Header().Add("Content-Disposition", filename.String())
182209}
183210
211// Downloaded is the result of a Download. A Status of 0 means the request never
212// completed, in which case Body and Headers are empty.
184213type Downloaded struct {
185214 Headers http.Header
186215 Status int
187216 Body []byte
188217}
189218
219// Download fetches urlString with the configured User-Agent, routing through
220// download-proxy when one is set. Every failure path returns the zero
221// Downloaded, so callers must check Status before trusting Body or Headers.
190222func Download(urlString string) (d Downloaded) {
191223 cli := &http.Client{}
192224 if CFG.DownloadProxy != "" {
193 u, e := url.Parse(CFG.DownloadProxy)
194 try(e)
195 cli.Transport = &http.Transport{Proxy: http.ProxyURL(u)}
225 u, err := url.Parse(CFG.DownloadProxy)
226 if err != nil {
227 try(err)
228 return
229 }
230 cli.Transport = ProxiedTransport(u)
196231 }
197232
198 req, e := http.NewRequest("GET", urlString, nil)
199 try(e)
233 ctx, cancel := context.WithTimeout(context.Background(), downloadTimeout)
234 defer cancel()
235
236 req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlString, nil)
237 if err != nil {
238 try(err)
239 return
240 }
200241 req.Header.Set("User-Agent", CFG.UserAgent)
201242
202 resp, e := cli.Do(req)
203 try(e)
204 defer resp.Body.Close()
205 b, e := io.ReadAll(resp.Body)
206 try(e)
243 resp, err := cli.Do(req)
244 if err != nil {
245 try(err)
246 return
247 }
248 defer func() { try(resp.Body.Close()) }()
249
250 b, err := io.ReadAll(resp.Body)
251 if err != nil {
252 try(err)
253 return
254 }
207255
208256 d.Body = b
209257 d.Status = resp.StatusCode
@@ -212,45 +260,56 @@ func Download(urlString string) (d Downloaded) {
212260}
213261
214262/* PARSING HELPERS */
263
264// ParseMedia returns the URL to serve for media: a link back through this
265// instance's media proxy when proxying is on, or DeviantArt's own URL when it is
266// off. An optional thumb width selects a thumbnail instead of the full image.
215267func ParseMedia(media devianter.Media, thumb ...int) string {
216 mediaUrl, filename := devianter.UrlFromMedia(media, thumb...)
217 if len(mediaUrl) != 0 && CFG.Proxy {
218 mediaUrl = mediaUrl[21:]
219 dot := strings.Index(mediaUrl, ".")
268 mediaURL, filename := devianter.UrlFromMedia(media, thumb...)
269 if len(mediaURL) != 0 && CFG.Proxy {
270 mediaURL = mediaURL[21:]
271 dot := strings.Index(mediaURL, ".")
220272 if filename == "" {
221273 filename = "image.gif"
222274 }
223 return UrlBuilder("media", "file", mediaUrl[:dot], mediaUrl[dot+11:], "&filename=", filename)
275 return URLBuilder("media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename)
224276 } else if !CFG.Proxy {
225 return mediaUrl
277 return mediaURL
226278 }
227279 return ""
228280}
229281
230func ConvertDeviantArtUrlToSkunkyArt(url string) (output string) {
282// ConvertDeviantArtURLToSkunkyArt rewrites a deviantart.com post link into the
283// equivalent link on this instance. It returns an empty string for URLs it does
284// not handle, including sta.sh links.
285func ConvertDeviantArtURLToSkunkyArt(url string) (output string) {
231286 if len(url) > 32 && url[27:32] != "stash" {
232287 url = url[27:]
233288 firstshash := strings.Index(url, "/")
234289 lastshash := firstshash + strings.Index(url[firstshash+1:], "/")
235290 if lastshash != -1 {
236 output = UrlBuilder("post", url[:firstshash], url[lastshash+2:])
291 output = URLBuilder("post", url[:firstshash], url[lastshash+2:])
237292 }
238293 }
239294 return
240295}
241296
297// BuildUserPlate renders the small avatar-and-username block linking to a user's
298// about page.
242299func BuildUserPlate(name string) string {
243300 var htm strings.Builder
244301 htm.WriteString(`<div class="user-plate"><img src="`)
245 htm.WriteString(UrlBuilder("media", "emojitar", name, "?type=a"))
302 htm.WriteString(URLBuilder("media", "emojitar", name, "?type=a"))
246303 htm.WriteString(`"><a href="`)
247 htm.WriteString(UrlBuilder("group_user", "?type=about&q=", name))
304 htm.WriteString(URLBuilder("group_user", "?type=about&q=", name))
248305 htm.WriteString(`">`)
249306 htm.WriteString(name)
250307 htm.WriteString(`</a></div>`)
251308 return htm.String()
252309}
253310
311// GetValueOfTag returns the text of the tokenizer's next token, or an empty
312// string if that token is not text.
254313func GetValueOfTag(t *html.Tokenizer) string {
255314 for tt := t.Next(); ; {
256315 if tt == html.TextToken {
@@ -261,13 +320,17 @@ func GetValueOfTag(t *html.Tokenizer) string {
261320 }
262321}
263322
264// page navigation
323// DeviationList describes the pagination state of a list of artworks: how many
324// pages exist, and whether another page follows the current one.
265325type DeviationList struct {
266326 Pages int
267327 More bool
268328}
269329
270// FIXME: on some artworks the first page can make the navigation panel disappear entirely.
330// NavBase renders the page navigation bar for a list.
331//
332// FIXME: on some artworks the first page can make the navigation panel disappear
333// entirely.
271334func (s skunkyart) NavBase(c DeviationList) string {
272335 var list strings.Builder
273336
app/wrapper.go +20 −12
@@ -10,6 +10,8 @@ import (
1010 "golang.org/x/net/html"
1111)
1212
13// GRUser renders a group or user page: the about tab, the gallery, or favourites,
14// selected by the request's type argument.
1315func (s skunkyart) GRUser() {
1416 if len(s.Query) < 1 {
1517 s.ReturnHTTPError(400)
@@ -70,7 +72,7 @@ func (s skunkyart) GRUser() {
7072
7173 case "cover_deviation":
7274 group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation
73 group.About.BGMeta.Url = ConvertDeviantArtUrlToSkunkyArt(group.About.BGMeta.Url)
75 group.About.BGMeta.Url = ConvertDeviantArtURLToSkunkyArt(group.About.BGMeta.Url)
7476 group.About.BG = ParseMedia(group.About.BGMeta.Media)
7577 case "group_admins":
7678 var htm strings.Builder
@@ -120,9 +122,9 @@ func (s skunkyart) GRUser() {
120122 if x.FolderId != -1 && x.Size != 0 {
121123 folders.WriteString(`<div class="block folder-item">`)
122124
123 if !(x.Thumb.NSFW && !CFG.Nsfw) {
125 if !x.Thumb.NSFW || CFG.Nsfw {
124126 folders.WriteString(`<a href="`)
125 folders.WriteString(ConvertDeviantArtUrlToSkunkyArt(x.Thumb.Url))
127 folders.WriteString(ConvertDeviantArtURLToSkunkyArt(x.Thumb.Url))
126128 folders.WriteString(`"><img loading="lazy" src="`)
127129 folders.WriteString(ParseMedia(x.Thumb.Media))
128130 folders.WriteString(`" title="`)
@@ -138,7 +140,7 @@ func (s skunkyart) GRUser() {
138140 folders.WriteString("&q=")
139141 folders.WriteString(s.Query)
140142 folders.WriteString("&type=")
141 folders.WriteString(string(s.Type))
143 folders.WriteRune(s.Type)
142144 folders.WriteString(`">`)
143145 folders.WriteString(x.Name)
144146 folders.WriteString(`</a>`)
@@ -167,10 +169,11 @@ func (s skunkyart) GRUser() {
167169 }
168170}
169171
170// posts
172// Deviation renders a single artwork page, with its description, tags, comments
173// and related work. It responds 403 for NSFW posts on instances that disallow them.
171174func (s skunkyart) Deviation(author, postname string) {
172 id_search := regexp.MustCompile("[0-9]+").FindAllString(postname, -1)
173 if len(id_search) < 1 {
175 idSearch := regexp.MustCompile("[0-9]+").FindAllString(postname, -1)
176 if len(idSearch) < 1 {
174177 s.ReturnHTTPError(400)
175178 return
176179 }
@@ -178,7 +181,7 @@ func (s skunkyart) Deviation(author, postname string) {
178181 var err devianter.Error
179182 post := &s.Templates.Deviation
180183
181 id := id_search[len(id_search)-1]
184 id := idSearch[len(idSearch)-1]
182185 post.Post, err = devianter.GetDeviation(id, author)
183186 if err.RAW != nil {
184187 s.Error(err)
@@ -188,7 +191,7 @@ func (s skunkyart) Deviation(author, postname string) {
188191 if post.Post.Deviation.NSFW && !CFG.Nsfw {
189192 s.Writer.WriteHeader(403)
190193 wr(s.Writer, `<html><link rel="stylesheet" href="`+
191 UrlBuilder("stylesheet")+
194 URLBuilder("stylesheet")+
192195 `" /><h1>NSFW content are disabled on this instance.</h1></html>`)
193196 return
194197 }
@@ -213,7 +216,7 @@ func (s skunkyart) Deviation(author, postname string) {
213216 for _, x := range post.Post.Deviation.Extended.Tags {
214217 var tag strings.Builder
215218 tag.WriteString(` <a href="`)
216 tag.WriteString(UrlBuilder("search", "?q=", x.Name, "&type=tag"))
219 tag.WriteString(URLBuilder("search", "?q=", x.Name, "&type=tag"))
217220 tag.WriteString(`">#`)
218221 tag.WriteString(x.Name)
219222 tag.WriteString("</a>")
@@ -228,6 +231,7 @@ func (s skunkyart) Deviation(author, postname string) {
228231 s.ExecuteTemplate("deviantion.htm", "html", &s)
229232}
230233
234// DD renders the Daily Deviations page, including each themed strip.
231235func (s skunkyart) DD() {
232236 dd, err := devianter.GetDailyDeviations(s.Page)
233237 if err.RAW != nil {
@@ -256,6 +260,8 @@ func (s skunkyart) DD() {
256260 }
257261}
258262
263// Search renders search results for the request's query. Group search is scraped
264// rather than fetched from the API, which DeviantArt does not expose to guests.
259265func (s skunkyart) Search() {
260266 if s.Query == "" {
261267 s.ReturnHTTPError(400)
@@ -305,7 +311,7 @@ func (s skunkyart) Search() {
305311
306312 if l := len(usernames); l != 0 {
307313 ss.List += `<div class="content plates">`
308 for x := 0; x < len(usernames); x++ {
314 for x := range len(usernames) {
309315 ss.List += BuildUserPlate(usernames[x])
310316 }
311317 ss.List += `</div>`
@@ -334,8 +340,10 @@ func (s skunkyart) Search() {
334340 s.ExecuteTemplate("search.htm", "html", &s)
335341}
336342
343// Emojitar proxies a user's avatar or emoji image, selected by the request's
344// type argument.
337345func (s skunkyart) Emojitar(name string) {
338 if name == "" || !(s.Type == 'a' || s.Type == 'e') {
346 if name == "" || (s.Type != 'a' && s.Type != 'e') {
339347 s.ReturnHTTPError(400)
340348 return
341349 }
static/doc.go added +8
@@ -0,0 +1,8 @@
1// Package static provides the HTML templates, stylesheet and images the
2// frontend serves.
3//
4// It has two implementations selected by the 'embed' build tag. With the tag,
5// the assets are compiled into the binary via go:embed. Without it, they are
6// read from the directory named by StaticPath at startup and held in memory,
7// which is what makes editing templates without a rebuild possible.
8package static
static/templates-noembed.go +26 −3
@@ -1,5 +1,4 @@
11//go:build !embed
2// +build !embed
32
43package static
54
@@ -11,6 +10,7 @@ import (
1110 "time"
1211)
1312
13// Templates is the in-memory asset filesystem populated by CopyTemplatesToMemory.
1414var Templates FS
1515
1616type file struct {
@@ -21,8 +21,12 @@ type file struct {
2121
2222var templateNames = []string{}
2323var templates = make(map[string][]file)
24
25// StaticPath is the directory assets are read from at startup.
2426var StaticPath string
2527
28// CopyTemplatesToMemory reads every asset under StaticPath into memory. It exits
29// the process on failure, since the frontend cannot serve anything without them.
2630func CopyTemplatesToMemory() {
2731 baseDir, err := os.ReadDir(StaticPath)
2832 try(err)
@@ -53,8 +57,11 @@ func CopyTemplatesToMemory() {
5357 }
5458}
5559
60// FS serves the in-memory assets. It implements the subset of fs.FS that
61// template.ParseFS requires.
5662type FS struct{}
5763
64// Open returns the asset stored at name, or an fs.PathError if there is none.
5865func (FS) Open(name string) (fs.File, error) {
5966 for i, l := 0, len(templateNames); i < l; i++ {
6067 for _, x := range templates[templateNames[i]] {
@@ -69,6 +76,8 @@ func (FS) Open(name string) (fs.File, error) {
6976 return nil, &fs.PathError{}
7077}
7178
79// Glob returns the paths of every asset in the directory named by pattern's
80// first segment, or an fs.PathError if none match.
7281func (FS) Glob(pattern string) ([]string, error) {
7382 trimmed := strings.Split(pattern, "/")
7483 var matches = []string{}
@@ -91,47 +100,60 @@ func try(err error) {
91100 }
92101}
93102
94/* based on https://github.com/psanford/memfs; required for templates.ParseFS to work correctly */
103// fileInfo is a minimal fs.FileInfo. Assets are held in memory and never stat'd
104// for anything but their name, so the remaining fields report fixed values.
105//
106// Based on https://github.com/psanford/memfs; required for templates.ParseFS to
107// work correctly.
95108type fileInfo struct {
96109 name string
97110}
98111
112// Name returns the asset's path.
99113func (fi fileInfo) Name() string {
100114 return fi.name
101115}
102116
117// Size reports a fixed placeholder size; callers here never use it.
103118func (fi fileInfo) Size() int64 {
104119 return 4096
105120}
106121
122// Mode reports no mode bits: in-memory assets have no filesystem permissions.
107123func (fileInfo) Mode() fs.FileMode {
108124 return 0
109125}
110126
127// ModTime reports the zero time, as in-memory assets are never modified.
111128func (fileInfo) ModTime() time.Time {
112129 return time.Time{}
113130}
114131
132// IsDir always reports false: only files are stored, never directories.
115133func (fileInfo) IsDir() bool {
116134 return false
117135}
118136
119func (fileInfo) Sys() interface{} {
137// Sys returns nil, as there is no underlying data source.
138func (fileInfo) Sys() any {
120139 return nil
121140}
122141
142// File is a read-once handle to an in-memory asset.
123143type File struct {
124144 name string
125145 content *bytes.Buffer
126146 closed bool
127147}
128148
149// Stat returns the file's fileInfo. It never fails.
129150func (f *File) Stat() (fs.FileInfo, error) {
130151 return fileInfo{
131152 name: f.name,
132153 }, nil
133154}
134155
156// Read consumes the asset's contents, reporting fs.ErrClosed once closed.
135157func (f *File) Read(b []byte) (int, error) {
136158 if f.closed {
137159 return 0, fs.ErrClosed
@@ -139,6 +161,7 @@ func (f *File) Read(b []byte) (int, error) {
139161 return f.content.Read(b)
140162}
141163
164// Close marks the file closed. Closing twice reports fs.ErrClosed.
142165func (f *File) Close() error {
143166 if f.closed {
144167 return fs.ErrClosed
static/templates.go +7 −1
@@ -1,16 +1,22 @@
11//go:build embed
2// +build embed
32
43package static
54
65import "embed"
76
7// Templates is the asset filesystem compiled into the binary.
8//
89//go:embed *
910var Templates embed.FS
11
12// Enabled reports that assets are embedded in this build.
1013var Enabled bool = true
1114
15// StaticPath is accepted for parity with the non-embed build, where it names the
16// directory assets are read from. It is ignored here.
1217var StaticPath string
1318
19// CopyTemplatesToMemory is a no-op in this build: the assets are already embedded.
1420func CopyTemplatesToMemory() {
1521 _ = StaticPath
1622}