app/util.go
500 lines · 14660 bytes · executable
1package app
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 htmlesc "html"
8 "html/template"
9 "io"
10 "net/http"
11 "net/url"
12 "os"
13 "skunkyart/static"
14 "strconv"
15 "strings"
16 "time"
17
18 "github.com/krazywarez/devianter"
19 "golang.org/x/net/html"
20)
21
22/* INTERNAL */
23
24// wr writes s to w. A write error here means the client went away mid-response,
25// which a handler cannot act on, so it is deliberately discarded.
26func wr(w io.Writer, s string) {
27 _, _ = io.WriteString(w, s)
28}
29
30// exit is a variable so a test can observe a fatal path without ending the
31// test binary.
32var exit = func(msg string, code int) {
33 println(msg)
34 os.Exit(code)
35}
36
37func try(e error) {
38 if e != nil {
39 println(e.Error())
40 }
41}
42func tryWithExitStatus(err error, code int) {
43 if err != nil {
44 exit(err.Error(), code)
45 }
46}
47
48// esc escapes s for use as HTML text or inside a quoted attribute. The Go-built
49// fragments bypass html/template's contextual escaping because they are handed
50// to it as template.HTML, so every DeviantArt-supplied string they contain has
51// to be escaped here instead.
52func esc(s string) string {
53 return htmlesc.EscapeString(s)
54}
55
56// restore swallows a panic in the calling goroutine so that one bad parse cannot
57// take the whole process down. The panic is logged rather than dropped silently.
58func restore() {
59 if r := recover(); r != nil {
60 println("recovered from panic:", fmt.Sprint(r))
61 }
62}
63
64var instances []byte
65
66// About is the instance list and settings shown in the frontend, refreshed by
67// RefreshInstances.
68var About instanceAbout
69
70// RefreshInstances re-fetches the published instance list every hour, forever.
71// Run it in its own goroutine; fetch failures are logged and retried next cycle.
72func RefreshInstances() {
73 for {
74 func() {
75 defer restore()
76 instances = Download("https://gitbay.org/krz/skunky-art/raw/main/instances.json").Body
77 try(json.Unmarshal(instances, &About))
78 }()
79 time.Sleep(1 * time.Hour)
80 }
81}
82
83// instanceAbout is the instance metadata exposed to the frontend and the API.
84type instanceAbout struct {
85 Proxy bool `json:"proxy"`
86 Nsfw bool `json:"nsfw"`
87 HideAI bool `json:"hide-ai"`
88 Theme string `json:"theme"`
89 Instances []settings `json:"instances"`
90}
91
92type skunkyart struct {
93 Writer http.ResponseWriter
94 _pth string
95
96 Args url.Values
97 Page int
98 Type rune
99 Atom bool
100
101 // Lang is the catalogue chosen for this request, resolved once in the
102 // handler so every template and helper agrees on one answer.
103 Lang string
104
105 // Host is the scheme and host this request arrived on, e.g.
106 // "https://art.example.com". It is per-request rather than global because
107 // concurrent requests can arrive on different hosts and ports.
108 Host string
109
110 BasePath, Endpoint string
111 Query, QueryRaw string
112
113 API API
114 Version string
115
116 // The template.HTML fields hold fragments the Go builders already
117 // escaped, so html/template inserts them as-is. Everything typed string is
118 // escaped by the template at the point of use.
119 Templates struct {
120 About instanceAbout
121
122 SomeList template.HTML
123 DDStrips template.HTML
124 Deviation struct {
125 Post devianter.Post
126 Description template.HTML
127 Related template.HTML
128 StringTime string
129 Tags template.HTML
130 Comments template.HTML
131 }
132
133 GroupUser struct {
134 GR devianter.GRuser
135 Admins template.HTML
136 Group bool
137 CreationDate string
138
139 About struct {
140 A devianter.About
141
142 DescriptionFormatted template.HTML
143 Interests, Social template.HTML
144 Comments template.HTML
145 BG string
146 BGMeta devianter.Deviation
147 }
148
149 Gallery struct {
150 Folders template.HTML
151 Pages int
152 List template.HTML
153 }
154 }
155 Search struct {
156 Content devianter.Search
157 List template.HTML
158 }
159 }
160}
161
162// pageTemplates is every page template parsed once per language, by
163// ParseTemplates. One set per language because T is bound at parse time, so
164// templates ask for a key and never have to know which catalogue answered.
165var pageTemplates = map[string]*template.Template{}
166
167// ParseTemplates parses static/html once for each loaded language. Call it at
168// startup after LoadLanguages; a template that does not parse exits the
169// process, since it would otherwise be a 500 on every request for that page.
170func ParseTemplates() {
171 langs := Languages()
172 if len(langs) == 0 {
173 langs = []string{DefaultLang}
174 }
175 for _, lang := range langs {
176 tmp := template.New("").Funcs(template.FuncMap{
177 "T": func(key string) string { return T(lang, key) },
178 })
179 tmp, err := tmp.ParseFS(static.Templates, "html/*")
180 if err != nil {
181 exit("templates: "+err.Error(), 1)
182 return
183 }
184 pageTemplates[lang] = tmp
185 }
186}
187
188// ExecuteTemplate renders the named page template with data in the request's
189// language, responding 500 if the templates were never parsed.
190func (s skunkyart) ExecuteTemplate(file, _ string, data any) {
191 tmp := pageTemplates[s.Lang]
192 if tmp == nil {
193 tmp = pageTemplates[DefaultLang]
194 }
195 if tmp == nil {
196 s.Writer.WriteHeader(500)
197 wr(s.Writer, "templates not parsed")
198 return
199 }
200 var buf strings.Builder
201 try(tmp.ExecuteTemplate(&buf, file, &data))
202 wr(s.Writer, buf.String())
203}
204
205// URLBuilder joins strs into an absolute instance URL, prefixing host and the
206// configured URI and inserting slashes between path segments but not before
207// query separators. host is the request's own scheme and host: passing the
208// wrong one emits links to another origin, which the instance's own
209// Content-Security-Policy then blocks.
210func URLBuilder(host string, strs ...string) string {
211 var str strings.Builder
212 l := len(strs)
213 str.WriteString(host)
214 str.WriteString(CFG.URI)
215 for n, x := range strs {
216 str.WriteString(x)
217 if n := n + 1; n < l && len(strs[n]) != 0 && (strs[n][0] != '?' && strs[n][0] != '&') && (x[0] != '?' && x[0] != '&') {
218 str.WriteString("/")
219 }
220 }
221 return str.String()
222}
223
224// Error responds 502 with the error DeviantArt reported upstream. Only the
225// first line is shown: a WAF block arrives as a whole HTML page, which is
226// neither readable nor safe to echo.
227func (s skunkyart) Error(dAerr devianter.Error) {
228 s.Writer.Header().Del("Cache-Control")
229
230 // A shed request is not an upstream failure: the instance is busy and the
231 // client should come back shortly rather than treat the page as broken.
232 if strings.Contains(dAerr.Error, errUpstreamBusy.Error()) {
233 s.Writer.Header().Set("Retry-After", "5")
234 s.ReturnHTTPError(http.StatusServiceUnavailable)
235 return
236 }
237 s.Writer.WriteHeader(502)
238
239 reason, _, _ := strings.Cut(dAerr.Error, "\n")
240
241 var msg strings.Builder
242 msg.WriteString(`<html><link rel="stylesheet" href="`)
243 msg.WriteString(URLBuilder(s.Host, "stylesheet"))
244 msg.WriteString(`" /><h3>` + esc(T(s.Lang, "error.upstream")) + ` — '`)
245 msg.WriteString(esc(reason))
246 msg.WriteString("'</h3></html>")
247
248 wr(s.Writer, msg.String())
249}
250
251// ReturnHTTPError responds with a styled error page for the given status.
252func (s skunkyart) ReturnHTTPError(status int) {
253 // A failed upstream fetch reports status 0, and WriteHeader panics on any
254 // code outside 1xx-5xx. Treat anything unusable as a gateway failure.
255 if status < 100 || status > 599 {
256 status = http.StatusBadGateway
257 }
258 s.Writer.Header().Del("Cache-Control")
259 s.Writer.WriteHeader(status)
260
261 var msg strings.Builder
262 msg.WriteString(`<html><link rel="stylesheet" href="`)
263 msg.WriteString(URLBuilder(s.Host, "stylesheet"))
264 msg.WriteString(`" /><h1>`)
265 msg.WriteString(strconv.Itoa(status))
266 msg.WriteString(" - ")
267 msg.WriteString(http.StatusText(status))
268 msg.WriteString("</h1></html>")
269
270 wr(s.Writer, msg.String())
271}
272
273// SetFilename sets the Content-Disposition filename for the response.
274func (s skunkyart) SetFilename(name string) {
275 var filename strings.Builder
276 filename.WriteString(`filename="`)
277 filename.WriteString(name)
278 filename.WriteString(`"`)
279 s.Writer.Header().Add("Content-Disposition", filename.String())
280}
281
282// Downloaded is the result of a Download. A Status of 0 means the request never
283// completed, in which case Body and Headers are empty.
284type Downloaded struct {
285 Headers http.Header
286 Status int
287 Body []byte
288}
289
290// Download fetches urlString with the configured User-Agent, routing through
291// download-proxy when one is set. Every failure path returns the zero
292// Downloaded, so callers must check Status before trusting Body or Headers.
293func Download(urlString string) (d Downloaded) {
294 cli := &http.Client{}
295 if CFG.DownloadProxy != "" {
296 u, err := url.Parse(CFG.DownloadProxy)
297 if err != nil {
298 try(err)
299 return
300 }
301 cli.Transport = ProxiedTransport(u)
302 }
303
304 ctx, cancel := context.WithTimeout(context.Background(), downloadTimeout)
305 defer cancel()
306
307 req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlString, nil)
308 if err != nil {
309 try(err)
310 return
311 }
312 req.Header.Set("User-Agent", CFG.UserAgent)
313
314 resp, err := cli.Do(req)
315 if err != nil {
316 try(err)
317 return
318 }
319 defer func() { try(resp.Body.Close()) }()
320
321 b, err := io.ReadAll(resp.Body)
322 if err != nil {
323 try(err)
324 return
325 }
326
327 d.Body = b
328 d.Status = resp.StatusCode
329 d.Headers = resp.Header
330 return
331}
332
333/* PARSING HELPERS */
334
335// wixmpMedia splits a wixmp CDN URL into the pieces the media proxy takes:
336// the variable hostname label, the path without its leading slash, and the
337// signing token. ok is false for anything that is not wixmp media.
338func wixmpMedia(raw string) (subdomain, path, token string, ok bool) {
339 u, err := url.Parse(raw)
340 if err != nil || !strings.HasPrefix(u.Host, "images-wixmp-") || !strings.HasSuffix(u.Host, ".wixmp.com") {
341 return "", "", "", false
342 }
343 subdomain = strings.TrimSuffix(strings.TrimPrefix(u.Host, "images-wixmp-"), ".wixmp.com")
344 return subdomain, strings.TrimPrefix(u.Path, "/"), u.Query().Get("token"), true
345}
346
347// proxiedMediaURL is the instance URL that serves raw through the media proxy,
348// with the token and a download filename as query parameters, or "" when raw is
349// not wixmp media. host is the request's scheme and host, as taken by URLBuilder.
350func proxiedMediaURL(host, raw, filename string) string {
351 subdomain, path, token, ok := wixmpMedia(raw)
352 if !ok {
353 return ""
354 }
355 q := url.Values{}
356 if token != "" {
357 q.Set("token", token)
358 }
359 if filename != "" {
360 q.Set("filename", filename)
361 }
362 return URLBuilder(host, "media", "file", subdomain, path) + "?" + q.Encode()
363}
364
365// ParseMedia returns the URL to serve for media: a link back through this
366// instance's media proxy when proxying is on, or DeviantArt's own URL when it is
367// off. An optional thumb width selects a thumbnail instead of the full image.
368// host is the request's scheme and host, as taken by URLBuilder.
369func ParseMedia(host string, media devianter.Media, thumb ...int) string {
370 mediaURL, filename := devianter.UrlFromMedia(media, thumb...)
371 if mediaURL == "" || !CFG.Proxy {
372 return mediaURL
373 }
374 if filename == "" {
375 filename = "image.gif"
376 }
377 return proxiedMediaURL(host, mediaURL, filename)
378}
379
380// ConvertDeviantArtURLToSkunkyArt rewrites a deviantart.com post link, in the
381// www.deviantart.com/<author>/art/<name> or <author>.deviantart.com/art/<name>
382// form, into the equivalent link on this instance. It returns "" for anything
383// else, including sta.sh links. host is the request's scheme and host, as
384// taken by URLBuilder.
385func ConvertDeviantArtURLToSkunkyArt(host, raw string) string {
386 u, err := url.Parse(raw)
387 if err != nil || !strings.HasSuffix(u.Host, "deviantart.com") {
388 return ""
389 }
390 parts := strings.Split(strings.Trim(u.Path, "/"), "/")
391 switch {
392 case len(parts) == 3 && parts[1] == "art" && parts[0] != "stash":
393 return URLBuilder(host, "post", parts[0], parts[2])
394 case len(parts) == 2 && parts[0] == "art" && u.Host != "www.deviantart.com":
395 return URLBuilder(host, "post", strings.TrimSuffix(u.Host, ".deviantart.com"), parts[1])
396 }
397 return ""
398}
399
400// BuildUserPlate renders the small avatar-and-username block linking to a user's
401// about page. host is the request's scheme and host, as taken by URLBuilder.
402func BuildUserPlate(host, name string) string {
403 var htm strings.Builder
404 htm.WriteString(`<div class="user-plate"><img src="`)
405 htm.WriteString(esc(URLBuilder(host, "media", "emojitar", name, "?type=a")))
406 htm.WriteString(`" alt="`)
407 htm.WriteString(esc(name))
408 htm.WriteString(`"><a href="`)
409 htm.WriteString(esc(URLBuilder(host, "group_user", "?type=about&q=", name)))
410 htm.WriteString(`">`)
411 htm.WriteString(esc(name))
412 htm.WriteString(`</a></div>`)
413 return htm.String()
414}
415
416// GetValueOfTag returns the text of the tokenizer's next token, or an empty
417// string if that token is not text.
418func GetValueOfTag(t *html.Tokenizer) string {
419 if t.Next() == html.TextToken {
420 return string(t.Text())
421 }
422 return ""
423}
424
425// DeviationList describes the pagination state of a list of artworks: how many
426// pages exist, and whether another page follows the current one.
427type DeviationList struct {
428 Pages int
429 More bool
430}
431
432// NavBase renders the page navigation bar for a list.
433func (s skunkyart) NavBase(c DeviationList) string {
434 var list strings.Builder
435
436 list.WriteString("<br>")
437 prevrev := func(msg string, page int, onpage bool) {
438 if !onpage {
439 list.WriteString(`<a href="`)
440 list.WriteString(esc(s._pth))
441 list.WriteString(`?p=`)
442 list.WriteString(strconv.Itoa(page))
443 if s.Type != 0 {
444 list.WriteString("&type=")
445 list.WriteRune(s.Type)
446 }
447 if s.Query != "" {
448 list.WriteString("&q=")
449 list.WriteString(esc(s.Query))
450 }
451 if f := s.Args.Get("folder"); f != "" {
452 list.WriteString("&folder=")
453 list.WriteString(esc(f))
454 }
455 if s.Args.Get("comments") != "" {
456 list.WriteString("&comments=1")
457 }
458 list.WriteString(`">`)
459 list.WriteString(msg)
460 list.WriteString("</a> ")
461 } else {
462 list.WriteString(strconv.Itoa(page))
463 list.WriteString(" ")
464 }
465 }
466
467 p := s.Page
468
469 if p > 1 {
470 prevrev("<= "+esc(T(s.Lang, "nav.prev"))+" |", p-1, false)
471 } else {
472 p = 1
473 }
474
475 // The window runs to the last page or the current one, whichever is further
476 // out. Callers that cannot count pages pass Pages: 0 — the comment list on an
477 // artwork is one — and bounding purely by Pages then ended the loop before
478 // i reached 1, so page one rendered no numbers at all. With nothing before it
479 // to link back to and no further page to link on, the whole panel came out as
480 // a bare <br>.
481 last := max(c.Pages, p)
482
483 for i, x := p-6, 0; (i <= last && i <= p+6) && x < 12; i++ {
484 if i > 0 {
485 var onPage bool
486 if i == p {
487 onPage = true
488 }
489
490 prevrev(strconv.Itoa(i), i, onPage)
491 x++
492 }
493 }
494
495 if c.More {
496 prevrev("| "+esc(T(s.Lang, "nav.next"))+" =>", p+1, false)
497 }
498
499 return list.String()
500}