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}