diff options
| author | Christian Cleberg <[email protected]> | 2026-07-15 02:25:20 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-07-15 02:25:20 -0500 |
| commit | d6789522d3dd076f7c98dafa3e7d42df09252093 (patch) | |
| tree | 4f27291432e367a32be581654b010ad19c5497aa | |
| parent | 7eb5e5e2230b6fb5b1bed6f0eaa03279aa61555b (diff) | |
| download | skunky-art-d6789522d3dd076f7c98dafa3e7d42df09252093.tar.gz skunky-art-d6789522d3dd076f7c98dafa3e7d42df09252093.tar.bz2 skunky-art-d6789522d3dd076f7c98dafa3e7d42df09252093.zip | |
fix: document exported API, fix naming, and harden Downloadv1.3.3
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.
| -rwxr-xr-x | app/config.go | 38 | ||||
| -rw-r--r-- | app/doc.go | 7 | ||||
| -rw-r--r-- | app/httpclient_test.go | 18 | ||||
| -rwxr-xr-x | app/parsers.go | 110 | ||||
| -rw-r--r-- | app/stat-darwin.go | 1 | ||||
| -rwxr-xr-x | app/util.go | 127 | ||||
| -rwxr-xr-x | app/wrapper.go | 32 | ||||
| -rw-r--r-- | static/doc.go | 8 | ||||
| -rwxr-xr-x | static/templates-noembed.go | 29 | ||||
| -rwxr-xr-x | 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 } |
