Commit d6789522d3
Verified · cmc
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. | ||
| 14 | var Release struct { | 16 | var Release struct { |
| 15 | Version string | 17 | Version string |
| 16 | Description string | 18 | Description string |
| 17 | } | 19 | } |
| 18 | 20 | ||
| 19 | type cache_config struct { | 21 | type 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 | ||
| 28 | type config struct { | 30 | type 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. | ||
| 39 | var CFG = config{ | 44 | var 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 | ||
| 54 | var lifetimeParsed int64 | 59 | var 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. | ||
| 56 | func ExecuteConfig() { | 64 | func 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. | ||
| 7 | package 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 | ||
| 24 | func newTestThrottle(base http.RoundTripper, gap time.Duration, max int) *daThrottle { | 24 | func 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. | ||
| 12 | func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) string { | 15 | func (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. | ||
| 79 | func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string { | 85 | func (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. | ||
| 184 | type text struct { | 194 | type 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. | ||
| 192 | func ParseDescription(dscr devianter.Text) string { | 206 | func 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 | ||
| 4 | package app | 3 | package app |
| 5 | 4 | ||
app/util.go +95 −32
| @@ -1,7 +1,9 @@ | |||
| 1 | package app | 1 | package app |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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 */ |
| 20 | var 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. | ||
| 25 | func wr(w io.Writer, s string) { | ||
| 26 | _, _ = io.WriteString(w, s) | ||
| 27 | } | ||
| 21 | 28 | ||
| 22 | func exit(msg string, code int) { | 29 | func 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. | ||
| 37 | func restore() { | 46 | func 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 | ||
| 43 | var instances []byte | 52 | var instances []byte |
| 53 | |||
| 54 | // About is the instance list and settings shown in the frontend, refreshed by | ||
| 55 | // RefreshInstances. | ||
| 44 | var About instanceAbout | 56 | var 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. | ||
| 46 | func RefreshInstances() { | 60 | func 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. |
| 58 | type instanceAbout struct { | 72 | type 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 | ||
| 64 | type skunkyart struct { | 78 | type 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. | ||
| 121 | func (s skunkyart) ExecuteTemplate(file, dir string, data any) { | 137 | func (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 | ||
| 134 | func 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. | ||
| 153 | func 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. | ||
| 148 | func (s skunkyart) Error(dAerr devianter.Error) { | 168 | func (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. | ||
| 161 | func (s skunkyart) ReturnHTTPError(status int) { | 182 | func (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. | ||
| 176 | func (s skunkyart) SetFilename(name string) { | 203 | func (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. | ||
| 184 | type Downloaded struct { | 213 | type 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. | ||
| 190 | func Download(urlString string) (d Downloaded) { | 222 | func 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. | ||
| 215 | func ParseMedia(media devianter.Media, thumb ...int) string { | 267 | func 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 | ||
| 230 | func 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. | ||
| 285 | func 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. | ||
| 242 | func BuildUserPlate(name string) string { | 299 | func 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. | ||
| 254 | func GetValueOfTag(t *html.Tokenizer) string { | 313 | func 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. | ||
| 265 | type DeviationList struct { | 325 | type 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. | ||
| 271 | func (s skunkyart) NavBase(c DeviationList) string { | 334 | func (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. | ||
| 13 | func (s skunkyart) GRUser() { | 15 | func (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. | ||
| 171 | func (s skunkyart) Deviation(author, postname string) { | 174 | func (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. | ||
| 231 | func (s skunkyart) DD() { | 235 | func (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. | ||
| 259 | func (s skunkyart) Search() { | 265 | func (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. | ||
| 337 | func (s skunkyart) Emojitar(name string) { | 345 | func (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. | ||
| 8 | package static | ||
static/templates-noembed.go +26 −3
| @@ -1,5 +1,4 @@ | |||
| 1 | //go:build !embed | 1 | //go:build !embed |
| 2 | // +build !embed | ||
| 3 | 2 | ||
| 4 | package static | 3 | package 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. | ||
| 14 | var Templates FS | 14 | var Templates FS |
| 15 | 15 | ||
| 16 | type file struct { | 16 | type file struct { |
| @@ -21,8 +21,12 @@ type file struct { | |||
| 21 | 21 | ||
| 22 | var templateNames = []string{} | 22 | var templateNames = []string{} |
| 23 | var templates = make(map[string][]file) | 23 | var templates = make(map[string][]file) |
| 24 | |||
| 25 | // StaticPath is the directory assets are read from at startup. | ||
| 24 | var StaticPath string | 26 | var 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. | ||
| 26 | func CopyTemplatesToMemory() { | 30 | func 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. | ||
| 56 | type FS struct{} | 62 | type FS struct{} |
| 57 | 63 | ||
| 64 | // Open returns the asset stored at name, or an fs.PathError if there is none. | ||
| 58 | func (FS) Open(name string) (fs.File, error) { | 65 | func (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. | ||
| 72 | func (FS) Glob(pattern string) ([]string, error) { | 81 | func (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. | ||
| 95 | type fileInfo struct { | 108 | type fileInfo struct { |
| 96 | name string | 109 | name string |
| 97 | } | 110 | } |
| 98 | 111 | ||
| 112 | // Name returns the asset's path. | ||
| 99 | func (fi fileInfo) Name() string { | 113 | func (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. | ||
| 103 | func (fi fileInfo) Size() int64 { | 118 | func (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. | ||
| 107 | func (fileInfo) Mode() fs.FileMode { | 123 | func (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. | ||
| 111 | func (fileInfo) ModTime() time.Time { | 128 | func (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. | ||
| 115 | func (fileInfo) IsDir() bool { | 133 | func (fileInfo) IsDir() bool { |
| 116 | return false | 134 | return false |
| 117 | } | 135 | } |
| 118 | 136 | ||
| 119 | func (fileInfo) Sys() interface{} { | 137 | // Sys returns nil, as there is no underlying data source. |
| 138 | func (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. | ||
| 123 | type File struct { | 143 | type 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. | ||
| 129 | func (f *File) Stat() (fs.FileInfo, error) { | 150 | func (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. | ||
| 135 | func (f *File) Read(b []byte) (int, error) { | 157 | func (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. | ||
| 142 | func (f *File) Close() error { | 165 | func (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 | ||
| 4 | package static | 3 | package static |
| 5 | 4 | ||
| 6 | import "embed" | 5 | import "embed" |
| 7 | 6 | ||
| 7 | // Templates is the asset filesystem compiled into the binary. | ||
| 8 | // | ||
| 8 | //go:embed * | 9 | //go:embed * |
| 9 | var Templates embed.FS | 10 | var Templates embed.FS |
| 11 | |||
| 12 | // Enabled reports that assets are embedded in this build. | ||
| 10 | var Enabled bool = true | 13 | var 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. | ||
| 12 | var StaticPath string | 17 | var StaticPath string |
| 13 | 18 | ||
| 19 | // CopyTemplatesToMemory is a no-op in this build: the assets are already embedded. | ||
| 14 | func CopyTemplatesToMemory() { | 20 | func CopyTemplatesToMemory() { |
| 15 | _ = StaticPath | 21 | _ = StaticPath |
| 16 | } | 22 | } |