summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rwxr-xr-xapp/config.go38
-rw-r--r--app/doc.go7
-rw-r--r--app/httpclient_test.go18
-rwxr-xr-xapp/parsers.go110
-rw-r--r--app/stat-darwin.go1
-rwxr-xr-xapp/util.go127
-rwxr-xr-xapp/wrapper.go32
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
}