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