app/util.go
465 lines · 13077 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 s.Writer.WriteHeader(502)
230
231 reason, _, _ := strings.Cut(dAerr.Error, "\n")
232
233 var msg strings.Builder
234 msg.WriteString(`<html><link rel="stylesheet" href="`)
235 msg.WriteString(URLBuilder(s.Host, "stylesheet"))
236 msg.WriteString(`" /><h3>` + esc(T(s.Lang, "error.upstream")) + ` — '`)
237 msg.WriteString(esc(reason))
238 msg.WriteString("'</h3></html>")
239
240 wr(s.Writer, msg.String())
241}
242
243// ReturnHTTPError responds with a styled error page for the given status.
244func (s skunkyart) ReturnHTTPError(status int) {
245 // A failed upstream fetch reports status 0, and WriteHeader panics on any
246 // code outside 1xx-5xx. Treat anything unusable as a gateway failure.
247 if status < 100 || status > 599 {
248 status = http.StatusBadGateway
249 }
250 s.Writer.Header().Del("Cache-Control")
251 s.Writer.WriteHeader(status)
252
253 var msg strings.Builder
254 msg.WriteString(`<html><link rel="stylesheet" href="`)
255 msg.WriteString(URLBuilder(s.Host, "stylesheet"))
256 msg.WriteString(`" /><h1>`)
257 msg.WriteString(strconv.Itoa(status))
258 msg.WriteString(" - ")
259 msg.WriteString(http.StatusText(status))
260 msg.WriteString("</h1></html>")
261
262 wr(s.Writer, msg.String())
263}
264
265// SetFilename sets the Content-Disposition filename for the response.
266func (s skunkyart) SetFilename(name string) {
267 var filename strings.Builder
268 filename.WriteString(`filename="`)
269 filename.WriteString(name)
270 filename.WriteString(`"`)
271 s.Writer.Header().Add("Content-Disposition", filename.String())
272}
273
274// Downloaded is the result of a Download. A Status of 0 means the request never
275// completed, in which case Body and Headers are empty.
276type Downloaded struct {
277 Headers http.Header
278 Status int
279 Body []byte
280}
281
282// Download fetches urlString with the configured User-Agent, routing through
283// download-proxy when one is set. Every failure path returns the zero
284// Downloaded, so callers must check Status before trusting Body or Headers.
285func Download(urlString string) (d Downloaded) {
286 cli := &http.Client{}
287 if CFG.DownloadProxy != "" {
288 u, err := url.Parse(CFG.DownloadProxy)
289 if err != nil {
290 try(err)
291 return
292 }
293 cli.Transport = ProxiedTransport(u)
294 }
295
296 ctx, cancel := context.WithTimeout(context.Background(), downloadTimeout)
297 defer cancel()
298
299 req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlString, nil)
300 if err != nil {
301 try(err)
302 return
303 }
304 req.Header.Set("User-Agent", CFG.UserAgent)
305
306 resp, err := cli.Do(req)
307 if err != nil {
308 try(err)
309 return
310 }
311 defer func() { try(resp.Body.Close()) }()
312
313 b, err := io.ReadAll(resp.Body)
314 if err != nil {
315 try(err)
316 return
317 }
318
319 d.Body = b
320 d.Status = resp.StatusCode
321 d.Headers = resp.Header
322 return
323}
324
325/* PARSING HELPERS */
326
327// ParseMedia returns the URL to serve for media: a link back through this
328// instance's media proxy when proxying is on, or DeviantArt's own URL when it is
329// off. An optional thumb width selects a thumbnail instead of the full image.
330// host is the request's scheme and host, as taken by URLBuilder.
331func ParseMedia(host string, media devianter.Media, thumb ...int) string {
332 mediaURL, filename := devianter.UrlFromMedia(media, thumb...)
333 if len(mediaURL) != 0 && CFG.Proxy {
334 mediaURL = mediaURL[21:]
335 dot := strings.Index(mediaURL, ".")
336 if filename == "" {
337 filename = "image.gif"
338 }
339 return URLBuilder(host, "media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename)
340 } else if !CFG.Proxy {
341 return mediaURL
342 }
343 return ""
344}
345
346// ConvertDeviantArtURLToSkunkyArt rewrites a deviantart.com post link into the
347// equivalent link on this instance. It returns an empty string for URLs it does
348// not handle, including sta.sh links. host is the request's scheme and host, as
349// taken by URLBuilder.
350func ConvertDeviantArtURLToSkunkyArt(host, url string) (output string) {
351 if len(url) > 32 && url[27:32] != "stash" {
352 url = url[27:]
353 firstshash := strings.Index(url, "/")
354 lastshash := firstshash + strings.Index(url[firstshash+1:], "/")
355 if lastshash != -1 {
356 output = URLBuilder(host, "post", url[:firstshash], url[lastshash+2:])
357 }
358 }
359 return
360}
361
362// BuildUserPlate renders the small avatar-and-username block linking to a user's
363// about page. host is the request's scheme and host, as taken by URLBuilder.
364func BuildUserPlate(host, name string) string {
365 var htm strings.Builder
366 htm.WriteString(`<div class="user-plate"><img src="`)
367 htm.WriteString(esc(URLBuilder(host, "media", "emojitar", name, "?type=a")))
368 htm.WriteString(`" alt="`)
369 htm.WriteString(esc(name))
370 htm.WriteString(`"><a href="`)
371 htm.WriteString(esc(URLBuilder(host, "group_user", "?type=about&q=", name)))
372 htm.WriteString(`">`)
373 htm.WriteString(esc(name))
374 htm.WriteString(`</a></div>`)
375 return htm.String()
376}
377
378// GetValueOfTag returns the text of the tokenizer's next token, or an empty
379// string if that token is not text.
380func GetValueOfTag(t *html.Tokenizer) string {
381 for tt := t.Next(); ; {
382 if tt == html.TextToken {
383 return string(t.Text())
384 } else {
385 return ""
386 }
387 }
388}
389
390// DeviationList describes the pagination state of a list of artworks: how many
391// pages exist, and whether another page follows the current one.
392type DeviationList struct {
393 Pages int
394 More bool
395}
396
397// NavBase renders the page navigation bar for a list.
398func (s skunkyart) NavBase(c DeviationList) string {
399 var list strings.Builder
400
401 list.WriteString("<br>")
402 prevrev := func(msg string, page int, onpage bool) {
403 if !onpage {
404 list.WriteString(`<a href="`)
405 list.WriteString(esc(s._pth))
406 list.WriteString(`?p=`)
407 list.WriteString(strconv.Itoa(page))
408 if s.Type != 0 {
409 list.WriteString("&type=")
410 list.WriteRune(s.Type)
411 }
412 if s.Query != "" {
413 list.WriteString("&q=")
414 list.WriteString(esc(s.Query))
415 }
416 if f := s.Args.Get("folder"); f != "" {
417 list.WriteString("&folder=")
418 list.WriteString(esc(f))
419 }
420 if s.Args.Get("comments") != "" {
421 list.WriteString("&comments=1")
422 }
423 list.WriteString(`">`)
424 list.WriteString(msg)
425 list.WriteString("</a> ")
426 } else {
427 list.WriteString(strconv.Itoa(page))
428 list.WriteString(" ")
429 }
430 }
431
432 p := s.Page
433
434 if p > 1 {
435 prevrev("<= "+esc(T(s.Lang, "nav.prev"))+" |", p-1, false)
436 } else {
437 p = 1
438 }
439
440 // The window runs to the last page or the current one, whichever is further
441 // out. Callers that cannot count pages pass Pages: 0 — the comment list on an
442 // artwork is one — and bounding purely by Pages then ended the loop before
443 // i reached 1, so page one rendered no numbers at all. With nothing before it
444 // to link back to and no further page to link on, the whole panel came out as
445 // a bare <br>.
446 last := max(c.Pages, p)
447
448 for i, x := p-6, 0; (i <= last && i <= p+6) && x < 12; i++ {
449 if i > 0 {
450 var onPage bool
451 if i == p {
452 onPage = true
453 }
454
455 prevrev(strconv.Itoa(i), i, onPage)
456 x++
457 }
458 }
459
460 if c.More {
461 prevrev("| "+esc(T(s.Lang, "nav.next"))+" =>", p+1, false)
462 }
463
464 return list.String()
465}