krz/skunky-art

Alternative privacy frontend for DeviantArt.

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

v1.3.6: 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/zerolabsco/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/zerolabsco/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	Instances []settings `json:"instances"`
 76}
 77
 78type skunkyart struct {
 79	Writer http.ResponseWriter
 80	_pth   string
 81
 82	Args url.Values
 83	Page int
 84	Type rune
 85	Atom bool
 86
 87	// Host is the scheme and host this request arrived on, e.g.
 88	// "https://art.example.com". It is per-request rather than global because
 89	// concurrent requests can arrive on different hosts and ports.
 90	Host string
 91
 92	BasePath, Endpoint string
 93	Query, QueryRaw    string
 94
 95	API     API
 96	Version string
 97
 98	Templates struct {
 99		About instanceAbout
100
101		SomeList  string
102		DDStrips  string
103		Deviation struct {
104			Post       devianter.Post
105			Related    string
106			StringTime string
107			Tags       string
108			Comments   string
109		}
110
111		GroupUser struct {
112			GR           devianter.GRuser
113			Admins       string
114			Group        bool
115			CreationDate string
116
117			About struct {
118				A devianter.About
119
120				DescriptionFormatted string
121				Interests, Social    string
122				Comments             string
123				BG                   string
124				BGMeta               devianter.Deviation
125			}
126
127			Gallery struct {
128				Folders string
129				Pages   int
130				List    string
131			}
132		}
133		Search struct {
134			Content devianter.Search
135			List    string
136		}
137	}
138}
139
140// ExecuteTemplate renders the named template from dir with data, responding 500
141// if the template cannot be parsed.
142func (s skunkyart) ExecuteTemplate(file, dir string, data any) {
143	var buf strings.Builder
144	tmp := template.New(file)
145	tmp, err := tmp.ParseFS(static.Templates, dir+"/*")
146	if err != nil {
147		s.Writer.WriteHeader(500)
148		wr(s.Writer, err.Error())
149		return
150	}
151	try(tmp.Execute(&buf, &data))
152	wr(s.Writer, buf.String())
153}
154
155// URLBuilder joins strs into an absolute instance URL, prefixing host and the
156// configured URI and inserting slashes between path segments but not before
157// query separators. host is the request's own scheme and host: passing the
158// wrong one emits links to another origin, which the instance's own
159// Content-Security-Policy then blocks.
160func URLBuilder(host string, strs ...string) string {
161	var str strings.Builder
162	l := len(strs)
163	str.WriteString(host)
164	str.WriteString(CFG.URI)
165	for n, x := range strs {
166		str.WriteString(x)
167		if n := n + 1; n < l && len(strs[n]) != 0 && (strs[n][0] != '?' && strs[n][0] != '&') && (x[0] != '?' && x[0] != '&') {
168			str.WriteString("/")
169		}
170	}
171	return str.String()
172}
173
174// Error responds 502 with the error DeviantArt reported upstream.
175func (s skunkyart) Error(dAerr devianter.Error) {
176	s.Writer.WriteHeader(502)
177
178	var msg strings.Builder
179	msg.WriteString(`<html><link rel="stylesheet" href="`)
180	msg.WriteString(URLBuilder(s.Host, "stylesheet"))
181	msg.WriteString(`" /><h3>DeviantArt error — '`)
182	msg.WriteString(dAerr.Error)
183	msg.WriteString("'</h3></html>")
184
185	wr(s.Writer, msg.String())
186}
187
188// ReturnHTTPError responds with a styled error page for the given status.
189func (s skunkyart) ReturnHTTPError(status int) {
190	// A failed upstream fetch reports status 0, and WriteHeader panics on any
191	// code outside 1xx-5xx. Treat anything unusable as a gateway failure.
192	if status < 100 || status > 599 {
193		status = http.StatusBadGateway
194	}
195	s.Writer.WriteHeader(status)
196
197	var msg strings.Builder
198	msg.WriteString(`<html><link rel="stylesheet" href="`)
199	msg.WriteString(URLBuilder(s.Host, "stylesheet"))
200	msg.WriteString(`" /><h1>`)
201	msg.WriteString(strconv.Itoa(status))
202	msg.WriteString(" - ")
203	msg.WriteString(http.StatusText(status))
204	msg.WriteString("</h1></html>")
205
206	wr(s.Writer, msg.String())
207}
208
209// SetFilename sets the Content-Disposition filename for the response.
210func (s skunkyart) SetFilename(name string) {
211	var filename strings.Builder
212	filename.WriteString(`filename="`)
213	filename.WriteString(name)
214	filename.WriteString(`"`)
215	s.Writer.Header().Add("Content-Disposition", filename.String())
216}
217
218// Downloaded is the result of a Download. A Status of 0 means the request never
219// completed, in which case Body and Headers are empty.
220type Downloaded struct {
221	Headers http.Header
222	Status  int
223	Body    []byte
224}
225
226// Download fetches urlString with the configured User-Agent, routing through
227// download-proxy when one is set. Every failure path returns the zero
228// Downloaded, so callers must check Status before trusting Body or Headers.
229func Download(urlString string) (d Downloaded) {
230	cli := &http.Client{}
231	if CFG.DownloadProxy != "" {
232		u, err := url.Parse(CFG.DownloadProxy)
233		if err != nil {
234			try(err)
235			return
236		}
237		cli.Transport = ProxiedTransport(u)
238	}
239
240	ctx, cancel := context.WithTimeout(context.Background(), downloadTimeout)
241	defer cancel()
242
243	req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlString, nil)
244	if err != nil {
245		try(err)
246		return
247	}
248	req.Header.Set("User-Agent", CFG.UserAgent)
249
250	resp, err := cli.Do(req)
251	if err != nil {
252		try(err)
253		return
254	}
255	defer func() { try(resp.Body.Close()) }()
256
257	b, err := io.ReadAll(resp.Body)
258	if err != nil {
259		try(err)
260		return
261	}
262
263	d.Body = b
264	d.Status = resp.StatusCode
265	d.Headers = resp.Header
266	return
267}
268
269/* PARSING HELPERS */
270
271// ParseMedia returns the URL to serve for media: a link back through this
272// instance's media proxy when proxying is on, or DeviantArt's own URL when it is
273// off. An optional thumb width selects a thumbnail instead of the full image.
274// host is the request's scheme and host, as taken by URLBuilder.
275func ParseMedia(host string, media devianter.Media, thumb ...int) string {
276	mediaURL, filename := devianter.UrlFromMedia(media, thumb...)
277	if len(mediaURL) != 0 && CFG.Proxy {
278		mediaURL = mediaURL[21:]
279		dot := strings.Index(mediaURL, ".")
280		if filename == "" {
281			filename = "image.gif"
282		}
283		return URLBuilder(host, "media", "file", mediaURL[:dot], mediaURL[dot+11:], "&filename=", filename)
284	} else if !CFG.Proxy {
285		return mediaURL
286	}
287	return ""
288}
289
290// ConvertDeviantArtURLToSkunkyArt rewrites a deviantart.com post link into the
291// equivalent link on this instance. It returns an empty string for URLs it does
292// not handle, including sta.sh links. host is the request's scheme and host, as
293// taken by URLBuilder.
294func ConvertDeviantArtURLToSkunkyArt(host, url string) (output string) {
295	if len(url) > 32 && url[27:32] != "stash" {
296		url = url[27:]
297		firstshash := strings.Index(url, "/")
298		lastshash := firstshash + strings.Index(url[firstshash+1:], "/")
299		if lastshash != -1 {
300			output = URLBuilder(host, "post", url[:firstshash], url[lastshash+2:])
301		}
302	}
303	return
304}
305
306// BuildUserPlate renders the small avatar-and-username block linking to a user's
307// about page. host is the request's scheme and host, as taken by URLBuilder.
308func BuildUserPlate(host, name string) string {
309	var htm strings.Builder
310	htm.WriteString(`<div class="user-plate"><img src="`)
311	htm.WriteString(URLBuilder(host, "media", "emojitar", name, "?type=a"))
312	htm.WriteString(`"><a href="`)
313	htm.WriteString(URLBuilder(host, "group_user", "?type=about&q=", name))
314	htm.WriteString(`">`)
315	htm.WriteString(name)
316	htm.WriteString(`</a></div>`)
317	return htm.String()
318}
319
320// GetValueOfTag returns the text of the tokenizer's next token, or an empty
321// string if that token is not text.
322func GetValueOfTag(t *html.Tokenizer) string {
323	for tt := t.Next(); ; {
324		if tt == html.TextToken {
325			return string(t.Text())
326		} else {
327			return ""
328		}
329	}
330}
331
332// DeviationList describes the pagination state of a list of artworks: how many
333// pages exist, and whether another page follows the current one.
334type DeviationList struct {
335	Pages int
336	More  bool
337}
338
339// NavBase renders the page navigation bar for a list.
340//
341// FIXME: on some artworks the first page can make the navigation panel disappear
342// entirely.
343func (s skunkyart) NavBase(c DeviationList) string {
344	var list strings.Builder
345
346	list.WriteString("<br>")
347	prevrev := func(msg string, page int, onpage bool) {
348		if !onpage {
349			list.WriteString(`<a href="`)
350			list.WriteString(s._pth)
351			list.WriteString(`?p=`)
352			list.WriteString(strconv.Itoa(page))
353			if s.Type != 0 {
354				list.WriteString("&type=")
355				list.WriteRune(s.Type)
356			}
357			if s.Query != "" {
358				list.WriteString("&q=")
359				list.WriteString(s.Query)
360			}
361			if f := s.Args.Get("folder"); f != "" {
362				list.WriteString("&folder=")
363				list.WriteString(f)
364			}
365			list.WriteString(`">`)
366			list.WriteString(msg)
367			list.WriteString("</a> ")
368		} else {
369			list.WriteString(strconv.Itoa(page))
370			list.WriteString(" ")
371		}
372	}
373
374	p := s.Page
375
376	if p > 1 {
377		prevrev("<= Prev |", p-1, false)
378	} else {
379		p = 1
380	}
381
382	for i, x := p-6, 0; (i <= c.Pages && i <= p+6) && x < 12; i++ {
383		if i > 0 {
384			var onPage bool
385			if i == p {
386				onPage = true
387			}
388
389			prevrev(strconv.Itoa(i), i, onPage)
390			x++
391		}
392	}
393
394	if c.More {
395		prevrev("| Next =>", p+1, false)
396	}
397
398	return list.String()
399}