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/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}