diff options
Diffstat (limited to 'app')
| -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 |
7 files changed, 218 insertions, 115 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 } |
