app/util.go
473 lines · 13396 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// ParseMedia returns the URL to serve for media: a link back through this
336// instance's media proxy when proxying is on, or DeviantArt's own URL when it is
337// off. An optional thumb width selects a thumbnail instead of the full image.
338// host is the request's scheme and host, as taken by URLBuilder.
339func ParseMedia(host string, media devianter.Media, thumb ...int) string {
340 mediaURL, filename := devianter.UrlFromMedia(media, thumb...)
341 if len(mediaURL) != 0 && CFG.Proxy {
342 mediaURL = mediaURL[21:]
343 dot := strings.Index(mediaURL, ".")
344 if filename == "" {
345 filename = "image.gif"
346 }
347 return URLBuilder(host, "media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename)
348 } else if !CFG.Proxy {
349 return mediaURL
350 }
351 return ""
352}
353
354// ConvertDeviantArtURLToSkunkyArt rewrites a deviantart.com post link into the
355// equivalent link on this instance. It returns an empty string for URLs it does
356// not handle, including sta.sh links. host is the request's scheme and host, as
357// taken by URLBuilder.
358func ConvertDeviantArtURLToSkunkyArt(host, url string) (output string) {
359 if len(url) > 32 && url[27:32] != "stash" {
360 url = url[27:]
361 firstshash := strings.Index(url, "/")
362 lastshash := firstshash + strings.Index(url[firstshash+1:], "/")
363 if lastshash != -1 {
364 output = URLBuilder(host, "post", url[:firstshash], url[lastshash+2:])
365 }
366 }
367 return
368}
369
370// BuildUserPlate renders the small avatar-and-username block linking to a user's
371// about page. host is the request's scheme and host, as taken by URLBuilder.
372func BuildUserPlate(host, name string) string {
373 var htm strings.Builder
374 htm.WriteString(`<div class="user-plate"><img src="`)
375 htm.WriteString(esc(URLBuilder(host, "media", "emojitar", name, "?type=a")))
376 htm.WriteString(`" alt="`)
377 htm.WriteString(esc(name))
378 htm.WriteString(`"><a href="`)
379 htm.WriteString(esc(URLBuilder(host, "group_user", "?type=about&q=", name)))
380 htm.WriteString(`">`)
381 htm.WriteString(esc(name))
382 htm.WriteString(`</a></div>`)
383 return htm.String()
384}
385
386// GetValueOfTag returns the text of the tokenizer's next token, or an empty
387// string if that token is not text.
388func GetValueOfTag(t *html.Tokenizer) string {
389 for tt := t.Next(); ; {
390 if tt == html.TextToken {
391 return string(t.Text())
392 } else {
393 return ""
394 }
395 }
396}
397
398// DeviationList describes the pagination state of a list of artworks: how many
399// pages exist, and whether another page follows the current one.
400type DeviationList struct {
401 Pages int
402 More bool
403}
404
405// NavBase renders the page navigation bar for a list.
406func (s skunkyart) NavBase(c DeviationList) string {
407 var list strings.Builder
408
409 list.WriteString("<br>")
410 prevrev := func(msg string, page int, onpage bool) {
411 if !onpage {
412 list.WriteString(`<a href="`)
413 list.WriteString(esc(s._pth))
414 list.WriteString(`?p=`)
415 list.WriteString(strconv.Itoa(page))
416 if s.Type != 0 {
417 list.WriteString("&type=")
418 list.WriteRune(s.Type)
419 }
420 if s.Query != "" {
421 list.WriteString("&q=")
422 list.WriteString(esc(s.Query))
423 }
424 if f := s.Args.Get("folder"); f != "" {
425 list.WriteString("&folder=")
426 list.WriteString(esc(f))
427 }
428 if s.Args.Get("comments") != "" {
429 list.WriteString("&comments=1")
430 }
431 list.WriteString(`">`)
432 list.WriteString(msg)
433 list.WriteString("</a> ")
434 } else {
435 list.WriteString(strconv.Itoa(page))
436 list.WriteString(" ")
437 }
438 }
439
440 p := s.Page
441
442 if p > 1 {
443 prevrev("<= "+esc(T(s.Lang, "nav.prev"))+" |", p-1, false)
444 } else {
445 p = 1
446 }
447
448 // The window runs to the last page or the current one, whichever is further
449 // out. Callers that cannot count pages pass Pages: 0 — the comment list on an
450 // artwork is one — and bounding purely by Pages then ended the loop before
451 // i reached 1, so page one rendered no numbers at all. With nothing before it
452 // to link back to and no further page to link on, the whole panel came out as
453 // a bare <br>.
454 last := max(c.Pages, p)
455
456 for i, x := p-6, 0; (i <= last && i <= p+6) && x < 12; i++ {
457 if i > 0 {
458 var onPage bool
459 if i == p {
460 onPage = true
461 }
462
463 prevrev(strconv.Itoa(i), i, onPage)
464 x++
465 }
466 }
467
468 if c.More {
469 prevrev("| "+esc(T(s.Lang, "nav.next"))+" =>", p+1, false)
470 }
471
472 return list.String()
473}