krz/skunky-art

Alternative privacy frontend for DeviantArt.

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

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