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 (
11 "github.com/zerolabsco/devianter" 11 "github.com/zerolabsco/devianter"
12) 12)
13 13
14// Release carries the build's version and description, set at link time and
15// shown by --help and the API.
14var Release struct { 16var Release struct {
15 Version string 17 Version string
16 Description string 18 Description string
17} 19}
18 20
19type cache_config struct { 21type cacheConfig struct {
20 Enabled bool 22 Enabled bool `json:"enabled"`
21 MemCache bool `json:"memcache"` 23 MemCache bool `json:"memcache"`
22 Path string 24 Path string `json:"path"`
23 MaxSize int64 `json:"max-size"` 25 MaxSize int64 `json:"max-size"`
24 Lifetime string 26 Lifetime string `json:"lifetime"`
25 UpdateInterval int64 `json:"update-interval"` 27 UpdateInterval int64 `json:"update-interval"`
26} 28}
27 29
28type config struct { 30type config struct {
29 cfg string 31 cfg string
30 Listen string 32 Listen string `json:"listen"`
31 URI string `json:"uri"` 33 URI string `json:"uri"`
32 Cache cache_config 34 Cache cacheConfig `json:"cache"`
33 Proxy, Nsfw bool 35 Proxy bool `json:"proxy"`
34 UserAgent string `json:"user-agent"` 36 Nsfw bool `json:"nsfw"`
35 DownloadProxy string `json:"download-proxy"` 37 UserAgent string `json:"user-agent"`
36 StaticPath string `json:"static-path"` 38 DownloadProxy string `json:"download-proxy"`
39 StaticPath string `json:"static-path"`
37} 40}
38 41
42// CFG is the running instance's configuration, holding the defaults below until
43// ExecuteConfig overwrites them from the config file.
39var CFG = config{ 44var CFG = config{
40 cfg: "config.json", 45 cfg: "config.json",
41 Listen: "127.0.0.1:3003", 46 Listen: "127.0.0.1:3003",
42 URI: "/", 47 URI: "/",
43 Cache: cache_config{ 48 Cache: cacheConfig{
44 Enabled: false, 49 Enabled: false,
45 Path: "cache", 50 Path: "cache",
46 UpdateInterval: 1, 51 UpdateInterval: 1,
@@ -53,6 +58,9 @@ var CFG = config{
53 58
54var lifetimeParsed int64 59var lifetimeParsed int64
55 60
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.
56func ExecuteConfig() { 64func ExecuteConfig() {
57 if CFG.cfg != "" { 65 if CFG.cfg != "" {
58 f, err := os.ReadFile(CFG.cfg) 66 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) {
21 return httptest.NewRecorder().Result(), nil 21 return httptest.NewRecorder().Result(), nil
22} 22}
23 23
24func newTestThrottle(base http.RoundTripper, gap time.Duration, max int) *daThrottle { 24func newTestThrottle(base http.RoundTripper, gap time.Duration, maxConcurrent int) *daThrottle {
25 return &daThrottle{base: base, sem: make(chan struct{}, max)} 25 return &daThrottle{base: base, sem: make(chan struct{}, maxConcurrent)}
26} 26}
27 27
28// DeviantArt requests must be spaced by at least daMinInterval. 28// DeviantArt requests must be spaced by at least daMinInterval.
@@ -32,7 +32,7 @@ func TestThrottleRateLimitsDeviantArt(t *testing.T) {
32 32
33 start := time.Now() 33 start := time.Now()
34 const n = 3 34 const n = 3
35 for i := 0; i < n; i++ { 35 for range n {
36 req, _ := http.NewRequest("GET", "https://www.deviantart.com/_puppy/x", nil) 36 req, _ := http.NewRequest("GET", "https://www.deviantart.com/_puppy/x", nil)
37 if _, err := tr.RoundTrip(req); err != nil { 37 if _, err := tr.RoundTrip(req); err != nil {
38 t.Fatalf("unexpected error: %v", err) 38 t.Fatalf("unexpected error: %v", err)
@@ -55,7 +55,7 @@ func TestThrottleSkipsOtherHosts(t *testing.T) {
55 tr := newTestThrottle(stub, daMinInterval, daMaxConcurrent) 55 tr := newTestThrottle(stub, daMinInterval, daMaxConcurrent)
56 56
57 start := time.Now() 57 start := time.Now()
58 for i := 0; i < 5; i++ { 58 for range 5 {
59 req, _ := http.NewRequest("GET", "https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/x.jpg", nil) 59 req, _ := http.NewRequest("GET", "https://images-wixmp-ed30a86b8c4ca887773594c2.wixmp.com/f/x.jpg", nil)
60 if _, err := tr.RoundTrip(req); err != nil { 60 if _, err := tr.RoundTrip(req); err != nil {
61 t.Fatalf("unexpected error: %v", err) 61 t.Fatalf("unexpected error: %v", err)
@@ -96,13 +96,11 @@ func TestThrottleCapsConcurrency(t *testing.T) {
96 tr := newTestThrottle(counting, daMinInterval, daMaxConcurrent) 96 tr := newTestThrottle(counting, daMinInterval, daMaxConcurrent)
97 97
98 var wg sync.WaitGroup 98 var wg sync.WaitGroup
99 for i := 0; i < 6; i++ { 99 for range 6 {
100 wg.Add(1) 100 wg.Go(func() {
101 go func() {
102 defer wg.Done()
103 req, _ := http.NewRequest("GET", "https://www.deviantart.com/_puppy/x", nil) 101 req, _ := http.NewRequest("GET", "https://www.deviantart.com/_puppy/x", nil)
104 tr.RoundTrip(req) 102 _, _ = tr.RoundTrip(req)
105 }() 103 })
106 } 104 }
107 wg.Wait() 105 wg.Wait()
108 106
app/parsers.go +65 −45
@@ -9,6 +9,9 @@ import (
9 "golang.org/x/net/html" 9 "golang.org/x/net/html"
10) 10)
11 11
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.
12func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) string { 15func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) string {
13 if daError.RAW != nil { 16 if daError.RAW != nil {
14 return "Failed to fetch comments :(" 17 return "Failed to fetch comments :("
@@ -29,9 +32,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error)
29 cmmts.WriteString(`"><p id="`) 32 cmmts.WriteString(`"><p id="`)
30 cmmts.WriteString(strconv.Itoa(x.ID)) 33 cmmts.WriteString(strconv.Itoa(x.ID))
31 cmmts.WriteString(`"><img src="`) 34 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"))
33 cmmts.WriteString(`" width="30px" height="30px"><a href="`) 36 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"))
35 cmmts.WriteString(`"><b`) 38 cmmts.WriteString(`"><b`)
36 cmmts.WriteString(` class="`) 39 cmmts.WriteString(` class="`)
37 if x.User.Banned { 40 if x.User.Banned {
@@ -76,6 +79,9 @@ func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error)
76 return cmmts.String() 79 return cmmts.String()
77} 80}
78 81
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.
79func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string { 85func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string {
80 if s.Atom && s.Page > 1 { 86 if s.Atom && s.Page > 1 {
81 s.ReturnHTTPError(400) 87 s.ReturnHTTPError(400)
@@ -86,16 +92,16 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
86 92
87 for i, l := 0, len(devs); i < l; i++ { 93 for i, l := 0, len(devs); i < l; i++ {
88 data := &devs[i] 94 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 {
90 if allowAtom && s.Atom { 96 if allowAtom && s.Atom {
91 s.Writer.Header().Add("Content-type", "application/atom+xml") 97 s.Writer.Header().Add("Content-Type", "application/atom+xml")
92 id := strconv.Itoa(data.ID) 98 id := strconv.Itoa(data.ID)
93 listContent.WriteString(`<entry><author><name>`) 99 listContent.WriteString(`<entry><author><name>`)
94 listContent.WriteString(data.Author.Username) 100 listContent.WriteString(data.Author.Username)
95 listContent.WriteString(`</name></author><title>`) 101 listContent.WriteString(`</name></author><title>`)
96 listContent.WriteString(data.Title) 102 listContent.WriteString(data.Title)
97 listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`) 103 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))
99 listContent.WriteString(`"/><id>`) 105 listContent.WriteString(`"/><id>`)
100 listContent.WriteString(id) 106 listContent.WriteString(id)
101 listContent.WriteString(`</id><published>`) 107 listContent.WriteString(`</id><published>`)
@@ -106,7 +112,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
106 listContent.WriteString(`</media:title><media:thumbinal url="`) 112 listContent.WriteString(`</media:title><media:thumbinal url="`)
107 listContent.WriteString(preview) 113 listContent.WriteString(preview)
108 listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`) 114 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))
110 listContent.WriteString(`"><img src="`) 116 listContent.WriteString(`"><img src="`)
111 listContent.WriteString(fullview) 117 listContent.WriteString(fullview)
112 listContent.WriteString(`"/></a><p>`) 118 listContent.WriteString(`"/></a><p>`)
@@ -124,7 +130,7 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
124 listContent.WriteString(`<h1>[ TEXT ]</h1>`) 130 listContent.WriteString(`<h1>[ TEXT ]</h1>`)
125 } 131 }
126 listContent.WriteString(`<br><a href="`) 132 listContent.WriteString(`<br><a href="`)
127 listContent.WriteString(ConvertDeviantArtUrlToSkunkyArt(data.Url)) 133 listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(data.Url))
128 listContent.WriteString(`">`) 134 listContent.WriteString(`">`)
129 listContent.WriteString(data.Author.Username) 135 listContent.WriteString(data.Author.Username)
130 listContent.WriteString(" - ") 136 listContent.WriteString(" - ")
@@ -149,11 +155,12 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
149 list.WriteString(`<?xml version="1.0" encoding="UTF-8"?><feed xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">`) 155 list.WriteString(`<?xml version="1.0" encoding="UTF-8"?><feed xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">`)
150 156
151 list.WriteString(`<title>`) 157 list.WriteString(`<title>`)
152 if s.Type == 0 { 158 switch {
159 case s.Type == 0:
153 list.WriteString("Daily Deviations") 160 list.WriteString("Daily Deviations")
154 } else if s.Type == 'g' && len(devs) != 0 { 161 case s.Type == 'g' && len(devs) != 0:
155 list.WriteString(devs[0].Author.Username) 162 list.WriteString(devs[0].Author.Username)
156 } else { 163 default:
157 list.WriteString("SkunkyArt") 164 list.WriteString("SkunkyArt")
158 } 165 }
159 list.WriteString(`</title>`) 166 list.WriteString(`</title>`)
@@ -181,19 +188,26 @@ func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, con
181} 188}
182 189
183/* DESCRIPTION/COMMENT PARSER */ 190/* 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.
184type text struct { 194type text struct {
185 TXT string 195 Txt string
186 TXT_RAW string 196 TxtRaw string
187 From int 197 From int
188 To int 198 To int
189} 199}
190 200
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.
192func ParseDescription(dscr devianter.Text) string { 206func ParseDescription(dscr devianter.Text) string {
193 var parsedDescription strings.Builder 207 var parsedDescription strings.Builder
194 TagBuilder := func(content string, tags ...string) string { 208 TagBuilder := func(content string, tags ...string) string {
195 l := len(tags) 209 l := len(tags)
196 for x := 0; x < l; x++ { 210 for x := range l {
197 var htm strings.Builder 211 var htm strings.Builder
198 htm.WriteString("<") 212 htm.WriteString("<")
199 htm.WriteString(tags[x]) 213 htm.WriteString(tags[x])
@@ -208,7 +222,7 @@ func ParseDescription(dscr devianter.Text) string {
208 } 222 }
209 return content 223 return content
210 } 224 }
211 DeleteTrackingFromUrl := func(url string) string { 225 DeleteTrackingFromURL := func(url string) string {
212 if len(url) > 42 && url[:42] == "https://www.deviantart.com/users/outgoing?" { 226 if len(url) > 42 && url[:42] == "https://www.deviantart.com/users/outgoing?" {
213 url = url[42:] 227 url = url[42:]
214 } 228 }
@@ -220,30 +234,34 @@ func ParseDescription(dscr devianter.Text) string {
220 description[dl-1] == '}' { 234 description[dl-1] == '}' {
221 var descr struct { 235 var descr struct {
222 Blocks []struct { 236 Blocks []struct {
223 Text, Type string 237 Text string `json:"text"`
238 Type string `json:"type"`
224 InlineStyleRanges []struct { 239 InlineStyleRanges []struct {
225 Offset, Length int 240 Offset int `json:"offset"`
226 Style string 241 Length int `json:"length"`
227 } 242 Style string `json:"style"`
243 } `json:"inlineStyleRanges"`
228 EntityRanges []struct { 244 EntityRanges []struct {
229 Offset, Length int 245 Offset int `json:"offset"`
230 Key int 246 Length int `json:"length"`
231 } 247 Key int `json:"key"`
248 } `json:"entityRanges"`
232 Data struct { 249 Data struct {
233 TextAlignment string 250 TextAlignment string `json:"textAlignment"`
234 } 251 } `json:"data"`
235 } 252 } `json:"blocks"`
236 EntityMap map[string]struct { 253 EntityMap map[string]struct {
237 Type string 254 Type string `json:"type"`
238 Data struct { 255 Data struct {
239 Url string 256 URL string `json:"url"`
240 Config struct { 257 Config struct {
241 Aligment string 258 // "aligment" is DeviantArt's own spelling; do not correct it.
242 Width int 259 Aligment string `json:"aligment"`
243 } 260 Width int `json:"width"`
244 Data devianter.Deviation 261 } `json:"config"`
245 } 262 Data devianter.Deviation `json:"data"`
246 } 263 } `json:"data"`
264 } `json:"entityMap"`
247 } 265 }
248 e := json.Unmarshal([]byte(description), &descr) 266 e := json.Unmarshal([]byte(description), &descr)
249 try(e) 267 try(e)
@@ -252,8 +270,8 @@ func ParseDescription(dscr devianter.Text) string {
252 urls := make(map[int]string) 270 urls := make(map[int]string)
253 for n, x := range descr.EntityMap { 271 for n, x := range descr.EntityMap {
254 num, _ := strconv.Atoi(n) 272 num, _ := strconv.Atoi(n)
255 if x.Data.Url != "" { 273 if x.Data.URL != "" {
256 urls[num] = DeleteTrackingFromUrl(x.Data.Url) 274 urls[num] = DeleteTrackingFromURL(x.Data.URL)
257 } 275 }
258 entities[num] = x.Data.Data 276 entities[num] = x.Data.Data
259 } 277 }
@@ -278,10 +296,10 @@ func ParseDescription(dscr devianter.Text) string {
278 FT := Styles.From * Styles.To 296 FT := Styles.From * Styles.To
279 tags[FT] = append(tags[FT], rngs.Style) 297 tags[FT] = append(tags[FT], rngs.Style)
280 } 298 }
281 for n := 0; n < len(Styles); n++ { 299 for n := range Styles {
282 Styles := &Styles[n] 300 Styles := &Styles[n]
283 Styles.TXT_RAW = x.Text[Styles.From:Styles.To] 301 Styles.TxtRaw = x.Text[Styles.From:Styles.To]
284 Styles.TXT = TagBuilder(Styles.TXT_RAW, tags[Styles.From*Styles.To]...) 302 Styles.Txt = TagBuilder(Styles.TxtRaw, tags[Styles.From*Styles.To]...)
285 } 303 }
286 } 304 }
287 305
@@ -290,7 +308,7 @@ func ParseDescription(dscr devianter.Text) string {
290 if len(x.EntityRanges) != 0 { 308 if len(x.EntityRanges) != 0 {
291 d := entities[x.EntityRanges[0].Key] 309 d := entities[x.EntityRanges[0].Key]
292 parsedDescription.WriteString(`<a href="`) 310 parsedDescription.WriteString(`<a href="`)
293 parsedDescription.WriteString(ConvertDeviantArtUrlToSkunkyArt(d.Url)) 311 parsedDescription.WriteString(ConvertDeviantArtURLToSkunkyArt(d.Url))
294 parsedDescription.WriteString(`"><img width="50%" src="`) 312 parsedDescription.WriteString(`"><img width="50%" src="`)
295 parsedDescription.WriteString(ParseMedia(d.Media)) 313 parsedDescription.WriteString(ParseMedia(d.Media))
296 parsedDescription.WriteString(`" title="`) 314 parsedDescription.WriteString(`" title="`)
@@ -314,10 +332,10 @@ func ParseDescription(dscr devianter.Text) string {
314 parsedDescription.WriteString(`<a target="_blank" href="`) 332 parsedDescription.WriteString(`<a target="_blank" href="`)
315 parsedDescription.WriteString(urls[ra.Key]) 333 parsedDescription.WriteString(urls[ra.Key])
316 parsedDescription.WriteString(`">`) 334 parsedDescription.WriteString(`">`)
317 parsedDescription.WriteString(r.TXT) 335 parsedDescription.WriteString(r.Txt)
318 parsedDescription.WriteString(`</a>`) 336 parsedDescription.WriteString(`</a>`)
319 } else if l > n+1 { 337 } else if l > n+1 {
320 parsedDescription.WriteString(r.TXT) 338 parsedDescription.WriteString(r.Txt)
321 } 339 }
322 parsedDescription.WriteString(TagBuilder(tag, x.Text[r.To:])) 340 parsedDescription.WriteString(TagBuilder(tag, x.Text[r.To:]))
323 } 341 }
@@ -332,13 +350,15 @@ func ParseDescription(dscr devianter.Text) string {
332 switch tt.Next() { 350 switch tt.Next() {
333 case html.ErrorToken: 351 case html.ErrorToken:
334 return parsedDescription.String() 352 return parsedDescription.String()
353 case html.CommentToken, html.DoctypeToken:
354 // No renderable content; skip.
335 case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken: 355 case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken:
336 token := tt.Token() 356 token := tt.Token()
337 switch token.Data { 357 switch token.Data {
338 case "a": 358 case "a":
339 for _, a := range token.Attr { 359 for _, a := range token.Attr {
340 if a.Key == "href" { 360 if a.Key == "href" {
341 url := DeleteTrackingFromUrl(a.Val) 361 url := DeleteTrackingFromURL(a.Val)
342 parsedDescription.WriteString(`<a target="_blank" href="`) 362 parsedDescription.WriteString(`<a target="_blank" href="`)
343 parsedDescription.WriteString(url) 363 parsedDescription.WriteString(url)
344 parsedDescription.WriteString(`">`) 364 parsedDescription.WriteString(`">`)
@@ -352,7 +372,7 @@ func ParseDescription(dscr devianter.Text) string {
352 switch a.Key { 372 switch a.Key {
353 case "src": 373 case "src":
354 if len(a.Val) > 9 && a.Val[8:9] == "e" { 374 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")
356 } 376 }
357 case "title": 377 case "title":
358 title = a.Val 378 title = a.Val
app/stat-darwin.go −1
@@ -1,5 +1,4 @@
1//go:build darwin 1//go:build darwin
2// +build darwin
3 2
4package app 3package app
5 4
app/util.go +95 −32
@@ -1,7 +1,9 @@
1package app 1package app
2 2
3import ( 3import (
4 "context"
4 "encoding/json" 5 "encoding/json"
6 "fmt"
5 "io" 7 "io"
6 "net/http" 8 "net/http"
7 "net/url" 9 "net/url"
@@ -17,7 +19,12 @@ import (
17) 19)
18 20
19/* INTERNAL */ 21/* 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}
21 28
22func exit(msg string, code int) { 29func exit(msg string, code int) {
23 println(msg) 30 println(msg)
@@ -34,15 +41,22 @@ func tryWithExitStatus(err error, code int) {
34 } 41 }
35} 42}
36 43
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.
37func restore() { 46func restore() {
38 if r := recover(); r != nil { 47 if r := recover(); r != nil {
39 recover() 48 println("recovered from panic:", fmt.Sprint(r))
40 } 49 }
41} 50}
42 51
43var instances []byte 52var instances []byte
53
54// About is the instance list and settings shown in the frontend, refreshed by
55// RefreshInstances.
44var About instanceAbout 56var About instanceAbout
45 57
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.
46func RefreshInstances() { 60func RefreshInstances() {
47 for { 61 for {
48 func() { 62 func() {
@@ -54,11 +68,11 @@ func RefreshInstances() {
54 } 68 }
55} 69}
56 70
57// some crap for frontend 71// instanceAbout is the instance metadata exposed to the frontend and the API.
58type instanceAbout struct { 72type instanceAbout struct {
59 Proxy bool 73 Proxy bool `json:"proxy"`
60 Nsfw bool 74 Nsfw bool `json:"nsfw"`
61 Instances []settings 75 Instances []settings `json:"instances"`
62} 76}
63 77
64type skunkyart struct { 78type skunkyart struct {
@@ -118,6 +132,8 @@ type skunkyart struct {
118 } 132 }
119} 133}
120 134
135// ExecuteTemplate renders the named template from dir with data, responding 500
136// if the template cannot be parsed.
121func (s skunkyart) ExecuteTemplate(file, dir string, data any) { 137func (s skunkyart) ExecuteTemplate(file, dir string, data any) {
122 var buf strings.Builder 138 var buf strings.Builder
123 tmp := template.New(file) 139 tmp := template.New(file)
@@ -131,26 +147,30 @@ func (s skunkyart) ExecuteTemplate(file, dir string, data any) {
131 wr(s.Writer, buf.String()) 147 wr(s.Writer, buf.String())
132} 148}
133 149
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 {
135 var str strings.Builder 154 var str strings.Builder
136 l := len(strs) 155 l := len(strs)
137 str.WriteString(Host) 156 str.WriteString(Host)
138 str.WriteString(CFG.URI) 157 str.WriteString(CFG.URI)
139 for n, x := range strs { 158 for n, x := range strs {
140 str.WriteString(x) 159 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] != '&') {
142 str.WriteString("/") 161 str.WriteString("/")
143 } 162 }
144 } 163 }
145 return str.String() 164 return str.String()
146} 165}
147 166
167// Error responds 502 with the error DeviantArt reported upstream.
148func (s skunkyart) Error(dAerr devianter.Error) { 168func (s skunkyart) Error(dAerr devianter.Error) {
149 s.Writer.WriteHeader(502) 169 s.Writer.WriteHeader(502)
150 170
151 var msg strings.Builder 171 var msg strings.Builder
152 msg.WriteString(`<html><link rel="stylesheet" href="`) 172 msg.WriteString(`<html><link rel="stylesheet" href="`)
153 msg.WriteString(UrlBuilder("stylesheet")) 173 msg.WriteString(URLBuilder("stylesheet"))
154 msg.WriteString(`" /><h3>DeviantArt error — '`) 174 msg.WriteString(`" /><h3>DeviantArt error — '`)
155 msg.WriteString(dAerr.Error) 175 msg.WriteString(dAerr.Error)
156 msg.WriteString("'</h3></html>") 176 msg.WriteString("'</h3></html>")
@@ -158,12 +178,18 @@ func (s skunkyart) Error(dAerr devianter.Error) {
158 wr(s.Writer, msg.String()) 178 wr(s.Writer, msg.String())
159} 179}
160 180
181// ReturnHTTPError responds with a styled error page for the given status.
161func (s skunkyart) ReturnHTTPError(status int) { 182func (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 }
162 s.Writer.WriteHeader(status) 188 s.Writer.WriteHeader(status)
163 189
164 var msg strings.Builder 190 var msg strings.Builder
165 msg.WriteString(`<html><link rel="stylesheet" href="`) 191 msg.WriteString(`<html><link rel="stylesheet" href="`)
166 msg.WriteString(UrlBuilder("stylesheet")) 192 msg.WriteString(URLBuilder("stylesheet"))
167 msg.WriteString(`" /><h1>`) 193 msg.WriteString(`" /><h1>`)
168 msg.WriteString(strconv.Itoa(status)) 194 msg.WriteString(strconv.Itoa(status))
169 msg.WriteString(" - ") 195 msg.WriteString(" - ")
@@ -173,6 +199,7 @@ func (s skunkyart) ReturnHTTPError(status int) {
173 wr(s.Writer, msg.String()) 199 wr(s.Writer, msg.String())
174} 200}
175 201
202// SetFilename sets the Content-Disposition filename for the response.
176func (s skunkyart) SetFilename(name string) { 203func (s skunkyart) SetFilename(name string) {
177 var filename strings.Builder 204 var filename strings.Builder
178 filename.WriteString(`filename="`) 205 filename.WriteString(`filename="`)
@@ -181,29 +208,50 @@ func (s skunkyart) SetFilename(name string) {
181 s.Writer.Header().Add("Content-Disposition", filename.String()) 208 s.Writer.Header().Add("Content-Disposition", filename.String())
182} 209}
183 210
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.
184type Downloaded struct { 213type Downloaded struct {
185 Headers http.Header 214 Headers http.Header
186 Status int 215 Status int
187 Body []byte 216 Body []byte
188} 217}
189 218
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.
190func Download(urlString string) (d Downloaded) { 222func Download(urlString string) (d Downloaded) {
191 cli := &http.Client{} 223 cli := &http.Client{}
192 if CFG.DownloadProxy != "" { 224 if CFG.DownloadProxy != "" {
193 u, e := url.Parse(CFG.DownloadProxy) 225 u, err := url.Parse(CFG.DownloadProxy)
194 try(e) 226 if err != nil {
195 cli.Transport = &http.Transport{Proxy: http.ProxyURL(u)} 227 try(err)
228 return
229 }
230 cli.Transport = ProxiedTransport(u)
196 } 231 }
197 232
198 req, e := http.NewRequest("GET", urlString, nil) 233 ctx, cancel := context.WithTimeout(context.Background(), downloadTimeout)
199 try(e) 234 defer cancel()
235
236 req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlString, nil)
237 if err != nil {
238 try(err)
239 return
240 }
200 req.Header.Set("User-Agent", CFG.UserAgent) 241 req.Header.Set("User-Agent", CFG.UserAgent)
201 242
202 resp, e := cli.Do(req) 243 resp, err := cli.Do(req)
203 try(e) 244 if err != nil {
204 defer resp.Body.Close() 245 try(err)
205 b, e := io.ReadAll(resp.Body) 246 return
206 try(e) 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 }
207 255
208 d.Body = b 256 d.Body = b
209 d.Status = resp.StatusCode 257 d.Status = resp.StatusCode
@@ -212,45 +260,56 @@ func Download(urlString string) (d Downloaded) {
212} 260}
213 261
214/* PARSING HELPERS */ 262/* 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.
215func ParseMedia(media devianter.Media, thumb ...int) string { 267func ParseMedia(media devianter.Media, thumb ...int) string {
216 mediaUrl, filename := devianter.UrlFromMedia(media, thumb...) 268 mediaURL, filename := devianter.UrlFromMedia(media, thumb...)
217 if len(mediaUrl) != 0 && CFG.Proxy { 269 if len(mediaURL) != 0 && CFG.Proxy {
218 mediaUrl = mediaUrl[21:] 270 mediaURL = mediaURL[21:]
219 dot := strings.Index(mediaUrl, ".") 271 dot := strings.Index(mediaURL, ".")
220 if filename == "" { 272 if filename == "" {
221 filename = "image.gif" 273 filename = "image.gif"
222 } 274 }
223 return UrlBuilder("media", "file", mediaUrl[:dot], mediaUrl[dot+11:], "&filename=", filename) 275 return URLBuilder("media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename)
224 } else if !CFG.Proxy { 276 } else if !CFG.Proxy {
225 return mediaUrl 277 return mediaURL
226 } 278 }
227 return "" 279 return ""
228} 280}
229 281
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) {
231 if len(url) > 32 && url[27:32] != "stash" { 286 if len(url) > 32 && url[27:32] != "stash" {
232 url = url[27:] 287 url = url[27:]
233 firstshash := strings.Index(url, "/") 288 firstshash := strings.Index(url, "/")
234 lastshash := firstshash + strings.Index(url[firstshash+1:], "/") 289 lastshash := firstshash + strings.Index(url[firstshash+1:], "/")
235 if lastshash != -1 { 290 if lastshash != -1 {
236 output = UrlBuilder("post", url[:firstshash], url[lastshash+2:]) 291 output = URLBuilder("post", url[:firstshash], url[lastshash+2:])
237 } 292 }
238 } 293 }
239 return 294 return
240} 295}
241 296
297// BuildUserPlate renders the small avatar-and-username block linking to a user's
298// about page.
242func BuildUserPlate(name string) string { 299func BuildUserPlate(name string) string {
243 var htm strings.Builder 300 var htm strings.Builder
244 htm.WriteString(`<div class="user-plate"><img src="`) 301 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"))
246 htm.WriteString(`"><a href="`) 303 htm.WriteString(`"><a href="`)
247 htm.WriteString(UrlBuilder("group_user", "?type=about&q=", name)) 304 htm.WriteString(URLBuilder("group_user", "?type=about&q=", name))
248 htm.WriteString(`">`) 305 htm.WriteString(`">`)
249 htm.WriteString(name) 306 htm.WriteString(name)
250 htm.WriteString(`</a></div>`) 307 htm.WriteString(`</a></div>`)
251 return htm.String() 308 return htm.String()
252} 309}
253 310
311// GetValueOfTag returns the text of the tokenizer's next token, or an empty
312// string if that token is not text.
254func GetValueOfTag(t *html.Tokenizer) string { 313func GetValueOfTag(t *html.Tokenizer) string {
255 for tt := t.Next(); ; { 314 for tt := t.Next(); ; {
256 if tt == html.TextToken { 315 if tt == html.TextToken {
@@ -261,13 +320,17 @@ func GetValueOfTag(t *html.Tokenizer) string {
261 } 320 }
262} 321}
263 322
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.
265type DeviationList struct { 325type DeviationList struct {
266 Pages int 326 Pages int
267 More bool 327 More bool
268} 328}
269 329
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.
271func (s skunkyart) NavBase(c DeviationList) string { 334func (s skunkyart) NavBase(c DeviationList) string {
272 var list strings.Builder 335 var list strings.Builder
273 336
app/wrapper.go +20 −12
@@ -10,6 +10,8 @@ import (
10 "golang.org/x/net/html" 10 "golang.org/x/net/html"
11) 11)
12 12
13// GRUser renders a group or user page: the about tab, the gallery, or favourites,
14// selected by the request's type argument.
13func (s skunkyart) GRUser() { 15func (s skunkyart) GRUser() {
14 if len(s.Query) < 1 { 16 if len(s.Query) < 1 {
15 s.ReturnHTTPError(400) 17 s.ReturnHTTPError(400)
@@ -70,7 +72,7 @@ func (s skunkyart) GRUser() {
70 72
71 case "cover_deviation": 73 case "cover_deviation":
72 group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation 74 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)
74 group.About.BG = ParseMedia(group.About.BGMeta.Media) 76 group.About.BG = ParseMedia(group.About.BGMeta.Media)
75 case "group_admins": 77 case "group_admins":
76 var htm strings.Builder 78 var htm strings.Builder
@@ -120,9 +122,9 @@ func (s skunkyart) GRUser() {
120 if x.FolderId != -1 && x.Size != 0 { 122 if x.FolderId != -1 && x.Size != 0 {
121 folders.WriteString(`<div class="block folder-item">`) 123 folders.WriteString(`<div class="block folder-item">`)
122 124
123 if !(x.Thumb.NSFW && !CFG.Nsfw) { 125 if !x.Thumb.NSFW || CFG.Nsfw {
124 folders.WriteString(`<a href="`) 126 folders.WriteString(`<a href="`)
125 folders.WriteString(ConvertDeviantArtUrlToSkunkyArt(x.Thumb.Url)) 127 folders.WriteString(ConvertDeviantArtURLToSkunkyArt(x.Thumb.Url))
126 folders.WriteString(`"><img loading="lazy" src="`) 128 folders.WriteString(`"><img loading="lazy" src="`)
127 folders.WriteString(ParseMedia(x.Thumb.Media)) 129 folders.WriteString(ParseMedia(x.Thumb.Media))
128 folders.WriteString(`" title="`) 130 folders.WriteString(`" title="`)
@@ -138,7 +140,7 @@ func (s skunkyart) GRUser() {
138 folders.WriteString("&q=") 140 folders.WriteString("&q=")
139 folders.WriteString(s.Query) 141 folders.WriteString(s.Query)
140 folders.WriteString("&type=") 142 folders.WriteString("&type=")
141 folders.WriteString(string(s.Type)) 143 folders.WriteRune(s.Type)
142 folders.WriteString(`">`) 144 folders.WriteString(`">`)
143 folders.WriteString(x.Name) 145 folders.WriteString(x.Name)
144 folders.WriteString(`</a>`) 146 folders.WriteString(`</a>`)
@@ -167,10 +169,11 @@ func (s skunkyart) GRUser() {
167 } 169 }
168} 170}
169 171
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.
171func (s skunkyart) Deviation(author, postname string) { 174func (s skunkyart) Deviation(author, postname string) {
172 id_search := regexp.MustCompile("[0-9]+").FindAllString(postname, -1) 175 idSearch := regexp.MustCompile("[0-9]+").FindAllString(postname, -1)
173 if len(id_search) < 1 { 176 if len(idSearch) < 1 {
174 s.ReturnHTTPError(400) 177 s.ReturnHTTPError(400)
175 return 178 return
176 } 179 }
@@ -178,7 +181,7 @@ func (s skunkyart) Deviation(author, postname string) {
178 var err devianter.Error 181 var err devianter.Error
179 post := &s.Templates.Deviation 182 post := &s.Templates.Deviation
180 183
181 id := id_search[len(id_search)-1] 184 id := idSearch[len(idSearch)-1]
182 post.Post, err = devianter.GetDeviation(id, author) 185 post.Post, err = devianter.GetDeviation(id, author)
183 if err.RAW != nil { 186 if err.RAW != nil {
184 s.Error(err) 187 s.Error(err)
@@ -188,7 +191,7 @@ func (s skunkyart) Deviation(author, postname string) {
188 if post.Post.Deviation.NSFW && !CFG.Nsfw { 191 if post.Post.Deviation.NSFW && !CFG.Nsfw {
189 s.Writer.WriteHeader(403) 192 s.Writer.WriteHeader(403)
190 wr(s.Writer, `<html><link rel="stylesheet" href="`+ 193 wr(s.Writer, `<html><link rel="stylesheet" href="`+
191 UrlBuilder("stylesheet")+ 194 URLBuilder("stylesheet")+
192 `" /><h1>NSFW content are disabled on this instance.</h1></html>`) 195 `" /><h1>NSFW content are disabled on this instance.</h1></html>`)
193 return 196 return
194 } 197 }
@@ -213,7 +216,7 @@ func (s skunkyart) Deviation(author, postname string) {
213 for _, x := range post.Post.Deviation.Extended.Tags { 216 for _, x := range post.Post.Deviation.Extended.Tags {
214 var tag strings.Builder 217 var tag strings.Builder
215 tag.WriteString(` <a href="`) 218 tag.WriteString(` <a href="`)
216 tag.WriteString(UrlBuilder("search", "?q=", x.Name, "&type=tag")) 219 tag.WriteString(URLBuilder("search", "?q=", x.Name, "&type=tag"))
217 tag.WriteString(`">#`) 220 tag.WriteString(`">#`)
218 tag.WriteString(x.Name) 221 tag.WriteString(x.Name)
219 tag.WriteString("</a>") 222 tag.WriteString("</a>")
@@ -228,6 +231,7 @@ func (s skunkyart) Deviation(author, postname string) {
228 s.ExecuteTemplate("deviantion.htm", "html", &s) 231 s.ExecuteTemplate("deviantion.htm", "html", &s)
229} 232}
230 233
234// DD renders the Daily Deviations page, including each themed strip.
231func (s skunkyart) DD() { 235func (s skunkyart) DD() {
232 dd, err := devianter.GetDailyDeviations(s.Page) 236 dd, err := devianter.GetDailyDeviations(s.Page)
233 if err.RAW != nil { 237 if err.RAW != nil {
@@ -256,6 +260,8 @@ func (s skunkyart) DD() {
256 } 260 }
257} 261}
258 262
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.
259func (s skunkyart) Search() { 265func (s skunkyart) Search() {
260 if s.Query == "" { 266 if s.Query == "" {
261 s.ReturnHTTPError(400) 267 s.ReturnHTTPError(400)
@@ -305,7 +311,7 @@ func (s skunkyart) Search() {
305 311
306 if l := len(usernames); l != 0 { 312 if l := len(usernames); l != 0 {
307 ss.List += `<div class="content plates">` 313 ss.List += `<div class="content plates">`
308 for x := 0; x < len(usernames); x++ { 314 for x := range len(usernames) {
309 ss.List += BuildUserPlate(usernames[x]) 315 ss.List += BuildUserPlate(usernames[x])
310 } 316 }
311 ss.List += `</div>` 317 ss.List += `</div>`
@@ -334,8 +340,10 @@ func (s skunkyart) Search() {
334 s.ExecuteTemplate("search.htm", "html", &s) 340 s.ExecuteTemplate("search.htm", "html", &s)
335} 341}
336 342
343// Emojitar proxies a user's avatar or emoji image, selected by the request's
344// type argument.
337func (s skunkyart) Emojitar(name string) { 345func (s skunkyart) Emojitar(name string) {
338 if name == "" || !(s.Type == 'a' || s.Type == 'e') { 346 if name == "" || (s.Type != 'a' && s.Type != 'e') {
339 s.ReturnHTTPError(400) 347 s.ReturnHTTPError(400)
340 return 348 return
341 } 349 }
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 @@
1//go:build !embed 1//go:build !embed
2// +build !embed
3 2
4package static 3package static
5 4
@@ -11,6 +10,7 @@ import (
11 "time" 10 "time"
12) 11)
13 12
13// Templates is the in-memory asset filesystem populated by CopyTemplatesToMemory.
14var Templates FS 14var Templates FS
15 15
16type file struct { 16type file struct {
@@ -21,8 +21,12 @@ type file struct {
21 21
22var templateNames = []string{} 22var templateNames = []string{}
23var templates = make(map[string][]file) 23var templates = make(map[string][]file)
24
25// StaticPath is the directory assets are read from at startup.
24var StaticPath string 26var StaticPath string
25 27
28// CopyTemplatesToMemory reads every asset under StaticPath into memory. It exits
29// the process on failure, since the frontend cannot serve anything without them.
26func CopyTemplatesToMemory() { 30func CopyTemplatesToMemory() {
27 baseDir, err := os.ReadDir(StaticPath) 31 baseDir, err := os.ReadDir(StaticPath)
28 try(err) 32 try(err)
@@ -53,8 +57,11 @@ func CopyTemplatesToMemory() {
53 } 57 }
54} 58}
55 59
60// FS serves the in-memory assets. It implements the subset of fs.FS that
61// template.ParseFS requires.
56type FS struct{} 62type FS struct{}
57 63
64// Open returns the asset stored at name, or an fs.PathError if there is none.
58func (FS) Open(name string) (fs.File, error) { 65func (FS) Open(name string) (fs.File, error) {
59 for i, l := 0, len(templateNames); i < l; i++ { 66 for i, l := 0, len(templateNames); i < l; i++ {
60 for _, x := range templates[templateNames[i]] { 67 for _, x := range templates[templateNames[i]] {
@@ -69,6 +76,8 @@ func (FS) Open(name string) (fs.File, error) {
69 return nil, &fs.PathError{} 76 return nil, &fs.PathError{}
70} 77}
71 78
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.
72func (FS) Glob(pattern string) ([]string, error) { 81func (FS) Glob(pattern string) ([]string, error) {
73 trimmed := strings.Split(pattern, "/") 82 trimmed := strings.Split(pattern, "/")
74 var matches = []string{} 83 var matches = []string{}
@@ -91,47 +100,60 @@ func try(err error) {
91 } 100 }
92} 101}
93 102
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.
95type fileInfo struct { 108type fileInfo struct {
96 name string 109 name string
97} 110}
98 111
112// Name returns the asset's path.
99func (fi fileInfo) Name() string { 113func (fi fileInfo) Name() string {
100 return fi.name 114 return fi.name
101} 115}
102 116
117// Size reports a fixed placeholder size; callers here never use it.
103func (fi fileInfo) Size() int64 { 118func (fi fileInfo) Size() int64 {
104 return 4096 119 return 4096
105} 120}
106 121
122// Mode reports no mode bits: in-memory assets have no filesystem permissions.
107func (fileInfo) Mode() fs.FileMode { 123func (fileInfo) Mode() fs.FileMode {
108 return 0 124 return 0
109} 125}
110 126
127// ModTime reports the zero time, as in-memory assets are never modified.
111func (fileInfo) ModTime() time.Time { 128func (fileInfo) ModTime() time.Time {
112 return time.Time{} 129 return time.Time{}
113} 130}
114 131
132// IsDir always reports false: only files are stored, never directories.
115func (fileInfo) IsDir() bool { 133func (fileInfo) IsDir() bool {
116 return false 134 return false
117} 135}
118 136
119func (fileInfo) Sys() interface{} { 137// Sys returns nil, as there is no underlying data source.
138func (fileInfo) Sys() any {
120 return nil 139 return nil
121} 140}
122 141
142// File is a read-once handle to an in-memory asset.
123type File struct { 143type File struct {
124 name string 144 name string
125 content *bytes.Buffer 145 content *bytes.Buffer
126 closed bool 146 closed bool
127} 147}
128 148
149// Stat returns the file's fileInfo. It never fails.
129func (f *File) Stat() (fs.FileInfo, error) { 150func (f *File) Stat() (fs.FileInfo, error) {
130 return fileInfo{ 151 return fileInfo{
131 name: f.name, 152 name: f.name,
132 }, nil 153 }, nil
133} 154}
134 155
156// Read consumes the asset's contents, reporting fs.ErrClosed once closed.
135func (f *File) Read(b []byte) (int, error) { 157func (f *File) Read(b []byte) (int, error) {
136 if f.closed { 158 if f.closed {
137 return 0, fs.ErrClosed 159 return 0, fs.ErrClosed
@@ -139,6 +161,7 @@ func (f *File) Read(b []byte) (int, error) {
139 return f.content.Read(b) 161 return f.content.Read(b)
140} 162}
141 163
164// Close marks the file closed. Closing twice reports fs.ErrClosed.
142func (f *File) Close() error { 165func (f *File) Close() error {
143 if f.closed { 166 if f.closed {
144 return fs.ErrClosed 167 return fs.ErrClosed
static/templates.go +7 −1
@@ -1,16 +1,22 @@
1//go:build embed 1//go:build embed
2// +build embed
3 2
4package static 3package static
5 4
6import "embed" 5import "embed"
7 6
7// Templates is the asset filesystem compiled into the binary.
8//
8//go:embed * 9//go:embed *
9var Templates embed.FS 10var Templates embed.FS
11
12// Enabled reports that assets are embedded in this build.
10var Enabled bool = true 13var Enabled bool = true
11 14
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.
12var StaticPath string 17var StaticPath string
13 18
19// CopyTemplatesToMemory is a no-op in this build: the assets are already embedded.
14func CopyTemplatesToMemory() { 20func CopyTemplatesToMemory() {
15 _ = StaticPath 21 _ = StaticPath
16} 22}