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}