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}