krz/skunky-art
Alternative privacy frontend for DeviantArt.
clone: git clone https://gitbay.org/krz/skunky-art.git
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}