krz/skunky-art

Alternative privacy frontend for DeviantArt.

clone: git clone https://gitbay.org/krz/skunky-art.git

main: app/util.go · raw

  1package app
  2
  3import (
  4	"context"
  5	"encoding/json"
  6	"fmt"
  7	"io"
  8	"net/http"
  9	"net/url"
 10	"os"
 11	"skunkyart/static"
 12	"strconv"
 13	"strings"
 14	"text/template"
 15	"time"
 16
 17	"github.com/krazywarez/devianter"
 18	"golang.org/x/net/html"
 19)
 20
 21/* INTERNAL */
 22
 23// wr writes s to w. A write error here means the client went away mid-response,
 24// which a handler cannot act on, so it is deliberately discarded.
 25func wr(w io.Writer, s string) {
 26	_, _ = io.WriteString(w, s)
 27}
 28
 29func exit(msg string, code int) {
 30	println(msg)
 31	os.Exit(code)
 32}
 33func try(e error) {
 34	if e != nil {
 35		println(e.Error())
 36	}
 37}
 38func tryWithExitStatus(err error, code int) {
 39	if err != nil {
 40		exit(err.Error(), code)
 41	}
 42}
 43
 44// restore swallows a panic in the calling goroutine so that one bad parse cannot
 45// take the whole process down. The panic is logged rather than dropped silently.
 46func restore() {
 47	if r := recover(); r != nil {
 48		println("recovered from panic:", fmt.Sprint(r))
 49	}
 50}
 51
 52var instances []byte
 53
 54// About is the instance list and settings shown in the frontend, refreshed by
 55// RefreshInstances.
 56var About instanceAbout
 57
 58// RefreshInstances re-fetches the published instance list every hour, forever.
 59// Run it in its own goroutine; fetch failures are logged and retried next cycle.
 60func RefreshInstances() {
 61	for {
 62		func() {
 63			defer restore()
 64			instances = Download("https://raw.githubusercontent.com/krazywarez/skunky-art/main/instances.json").Body
 65			try(json.Unmarshal(instances, &About))
 66		}()
 67		time.Sleep(1 * time.Hour)
 68	}
 69}
 70
 71// instanceAbout is the instance metadata exposed to the frontend and the API.
 72type instanceAbout struct {
 73	Proxy     bool       `json:"proxy"`
 74	Nsfw      bool       `json:"nsfw"`
 75	HideAI    bool       `json:"hide-ai"`
 76	Theme     string     `json:"theme"`
 77	Instances []settings `json:"instances"`
 78}
 79
 80type skunkyart struct {
 81	Writer http.ResponseWriter
 82	_pth   string
 83
 84	Args url.Values
 85	Page int
 86	Type rune
 87	Atom bool
 88
 89	// Lang is the catalogue chosen for this request, resolved once in the
 90	// handler so every template and helper agrees on one answer.
 91	Lang string
 92
 93	// Host is the scheme and host this request arrived on, e.g.
 94	// "https://art.example.com". It is per-request rather than global because
 95	// concurrent requests can arrive on different hosts and ports.
 96	Host string
 97
 98	BasePath, Endpoint string
 99	Query, QueryRaw    string
100
101	API     API
102	Version string
103
104	Templates struct {
105		About instanceAbout
106
107		SomeList  string
108		DDStrips  string
109		Deviation struct {
110			Post       devianter.Post
111			Related    string
112			StringTime string
113			Tags       string
114			Comments   string
115		}
116
117		GroupUser struct {
118			GR           devianter.GRuser
119			Admins       string
120			Group        bool
121			CreationDate string
122
123			About struct {
124				A devianter.About
125
126				DescriptionFormatted string
127				Interests, Social    string
128				Comments             string
129				BG                   string
130				BGMeta               devianter.Deviation
131			}
132
133			Gallery struct {
134				Folders string
135				Pages   int
136				List    string
137			}
138		}
139		Search struct {
140			Content devianter.Search
141			List    string
142		}
143	}
144}
145
146// ExecuteTemplate renders the named template from dir with data, responding 500
147// if the template cannot be parsed.
148func (s skunkyart) ExecuteTemplate(file, dir string, data any) {
149	var buf strings.Builder
150	tmp := template.New(file)
151	// T is bound to this request's language, so templates ask for a key and
152	// never have to know which catalogue answered.
153	tmp = tmp.Funcs(template.FuncMap{
154		"T": func(key string) string { return T(s.Lang, key) },
155	})
156	tmp, err := tmp.ParseFS(static.Templates, dir+"/*")
157	if err != nil {
158		s.Writer.WriteHeader(500)
159		wr(s.Writer, err.Error())
160		return
161	}
162	try(tmp.Execute(&buf, &data))
163	wr(s.Writer, buf.String())
164}
165
166// URLBuilder joins strs into an absolute instance URL, prefixing host and the
167// configured URI and inserting slashes between path segments but not before
168// query separators. host is the request's own scheme and host: passing the
169// wrong one emits links to another origin, which the instance's own
170// Content-Security-Policy then blocks.
171func URLBuilder(host string, strs ...string) string {
172	var str strings.Builder
173	l := len(strs)
174	str.WriteString(host)
175	str.WriteString(CFG.URI)
176	for n, x := range strs {
177		str.WriteString(x)
178		if n := n + 1; n < l && len(strs[n]) != 0 && (strs[n][0] != '?' && strs[n][0] != '&') && (x[0] != '?' && x[0] != '&') {
179			str.WriteString("/")
180		}
181	}
182	return str.String()
183}
184
185// Error responds 502 with the error DeviantArt reported upstream.
186func (s skunkyart) Error(dAerr devianter.Error) {
187	s.Writer.WriteHeader(502)
188
189	var msg strings.Builder
190	msg.WriteString(`<html><link rel="stylesheet" href="`)
191	msg.WriteString(URLBuilder(s.Host, "stylesheet"))
192	msg.WriteString(`" /><h3>DeviantArt error — '`)
193	msg.WriteString(dAerr.Error)
194	msg.WriteString("'</h3></html>")
195
196	wr(s.Writer, msg.String())
197}
198
199// ReturnHTTPError responds with a styled error page for the given status.
200func (s skunkyart) ReturnHTTPError(status int) {
201	// A failed upstream fetch reports status 0, and WriteHeader panics on any
202	// code outside 1xx-5xx. Treat anything unusable as a gateway failure.
203	if status < 100 || status > 599 {
204		status = http.StatusBadGateway
205	}
206	s.Writer.WriteHeader(status)
207
208	var msg strings.Builder
209	msg.WriteString(`<html><link rel="stylesheet" href="`)
210	msg.WriteString(URLBuilder(s.Host, "stylesheet"))
211	msg.WriteString(`" /><h1>`)
212	msg.WriteString(strconv.Itoa(status))
213	msg.WriteString(" - ")
214	msg.WriteString(http.StatusText(status))
215	msg.WriteString("</h1></html>")
216
217	wr(s.Writer, msg.String())
218}
219
220// SetFilename sets the Content-Disposition filename for the response.
221func (s skunkyart) SetFilename(name string) {
222	var filename strings.Builder
223	filename.WriteString(`filename="`)
224	filename.WriteString(name)
225	filename.WriteString(`"`)
226	s.Writer.Header().Add("Content-Disposition", filename.String())
227}
228
229// Downloaded is the result of a Download. A Status of 0 means the request never
230// completed, in which case Body and Headers are empty.
231type Downloaded struct {
232	Headers http.Header
233	Status  int
234	Body    []byte
235}
236
237// Download fetches urlString with the configured User-Agent, routing through
238// download-proxy when one is set. Every failure path returns the zero
239// Downloaded, so callers must check Status before trusting Body or Headers.
240func Download(urlString string) (d Downloaded) {
241	cli := &http.Client{}
242	if CFG.DownloadProxy != "" {
243		u, err := url.Parse(CFG.DownloadProxy)
244		if err != nil {
245			try(err)
246			return
247		}
248		cli.Transport = ProxiedTransport(u)
249	}
250
251	ctx, cancel := context.WithTimeout(context.Background(), downloadTimeout)
252	defer cancel()
253
254	req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlString, nil)
255	if err != nil {
256		try(err)
257		return
258	}
259	req.Header.Set("User-Agent", CFG.UserAgent)
260
261	resp, err := cli.Do(req)
262	if err != nil {
263		try(err)
264		return
265	}
266	defer func() { try(resp.Body.Close()) }()
267
268	b, err := io.ReadAll(resp.Body)
269	if err != nil {
270		try(err)
271		return
272	}
273
274	d.Body = b
275	d.Status = resp.StatusCode
276	d.Headers = resp.Header
277	return
278}
279
280/* PARSING HELPERS */
281
282// ParseMedia returns the URL to serve for media: a link back through this
283// instance's media proxy when proxying is on, or DeviantArt's own URL when it is
284// off. An optional thumb width selects a thumbnail instead of the full image.
285// host is the request's scheme and host, as taken by URLBuilder.
286func ParseMedia(host string, media devianter.Media, thumb ...int) string {
287	mediaURL, filename := devianter.UrlFromMedia(media, thumb...)
288	if len(mediaURL) != 0 && CFG.Proxy {
289		mediaURL = mediaURL[21:]
290		dot := strings.Index(mediaURL, ".")
291		if filename == "" {
292			filename = "image.gif"
293		}
294		return URLBuilder(host, "media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename)
295	} else if !CFG.Proxy {
296		return mediaURL
297	}
298	return ""
299}
300
301// ConvertDeviantArtURLToSkunkyArt rewrites a deviantart.com post link into the
302// equivalent link on this instance. It returns an empty string for URLs it does
303// not handle, including sta.sh links. host is the request's scheme and host, as
304// taken by URLBuilder.
305func ConvertDeviantArtURLToSkunkyArt(host, url string) (output string) {
306	if len(url) > 32 && url[27:32] != "stash" {
307		url = url[27:]
308		firstshash := strings.Index(url, "/")
309		lastshash := firstshash + strings.Index(url[firstshash+1:], "/")
310		if lastshash != -1 {
311			output = URLBuilder(host, "post", url[:firstshash], url[lastshash+2:])
312		}
313	}
314	return
315}
316
317// BuildUserPlate renders the small avatar-and-username block linking to a user's
318// about page. host is the request's scheme and host, as taken by URLBuilder.
319func BuildUserPlate(host, name string) string {
320	var htm strings.Builder
321	htm.WriteString(`<div class="user-plate"><img src="`)
322	htm.WriteString(URLBuilder(host, "media", "emojitar", name, "?type=a"))
323	htm.WriteString(`"><a href="`)
324	htm.WriteString(URLBuilder(host, "group_user", "?type=about&q=", name))
325	htm.WriteString(`">`)
326	htm.WriteString(name)
327	htm.WriteString(`</a></div>`)
328	return htm.String()
329}
330
331// GetValueOfTag returns the text of the tokenizer's next token, or an empty
332// string if that token is not text.
333func GetValueOfTag(t *html.Tokenizer) string {
334	for tt := t.Next(); ; {
335		if tt == html.TextToken {
336			return string(t.Text())
337		} else {
338			return ""
339		}
340	}
341}
342
343// DeviationList describes the pagination state of a list of artworks: how many
344// pages exist, and whether another page follows the current one.
345type DeviationList struct {
346	Pages int
347	More  bool
348}
349
350// NavBase renders the page navigation bar for a list.
351func (s skunkyart) NavBase(c DeviationList) string {
352	var list strings.Builder
353
354	list.WriteString("<br>")
355	prevrev := func(msg string, page int, onpage bool) {
356		if !onpage {
357			list.WriteString(`<a href="`)
358			list.WriteString(s._pth)
359			list.WriteString(`?p=`)
360			list.WriteString(strconv.Itoa(page))
361			if s.Type != 0 {
362				list.WriteString("&type=")
363				list.WriteRune(s.Type)
364			}
365			if s.Query != "" {
366				list.WriteString("&q=")
367				list.WriteString(s.Query)
368			}
369			if f := s.Args.Get("folder"); f != "" {
370				list.WriteString("&folder=")
371				list.WriteString(f)
372			}
373			list.WriteString(`">`)
374			list.WriteString(msg)
375			list.WriteString("</a> ")
376		} else {
377			list.WriteString(strconv.Itoa(page))
378			list.WriteString(" ")
379		}
380	}
381
382	p := s.Page
383
384	if p > 1 {
385		prevrev("<= Prev |", p-1, false)
386	} else {
387		p = 1
388	}
389
390	// The window runs to the last page or the current one, whichever is further
391	// out. Callers that cannot count pages pass Pages: 0 — the comment list on an
392	// artwork is one — and bounding purely by Pages then ended the loop before
393	// i reached 1, so page one rendered no numbers at all. With nothing before it
394	// to link back to and no further page to link on, the whole panel came out as
395	// a bare <br>.
396	last := c.Pages
397	if p > last {
398		last = p
399	}
400
401	for i, x := p-6, 0; (i <= last && i <= p+6) && x < 12; i++ {
402		if i > 0 {
403			var onPage bool
404			if i == p {
405				onPage = true
406			}
407
408			prevrev(strconv.Itoa(i), i, onPage)
409			x++
410		}
411	}
412
413	if c.More {
414		prevrev("| Next =>", p+1, false)
415	}
416
417	return list.String()
418}