krz/skunky-art
Alternative privacy frontend for DeviantArt.
clone: git clone https://gitbay.org/krz/skunky-art.git
1package app
2
3import (
4 "encoding/json"
5 "strconv"
6 "strings"
7
8 "github.com/krazywarez/devianter"
9 "golang.org/x/net/html"
10)
11
12// ParseComments renders a comment thread, nesting replies under the comment they
13// answer. It returns a placeholder message rather than failing if the upstream
14// fetch errored.
15func (s skunkyart) ParseComments(c devianter.Comments, daError devianter.Error) string {
16 if daError.RAW != nil {
17 return "Failed to fetch comments :("
18 }
19
20 var cmmts strings.Builder
21 replied := make(map[int]string)
22
23 cmmts.WriteString("<details><summary>Comments: <b>")
24 cmmts.WriteString(strconv.Itoa(c.Total))
25 cmmts.WriteString("</b></summary>")
26 for _, x := range c.Thread {
27 replied[x.ID] = x.User.Username
28 cmmts.WriteString(`<div class="msg`)
29 if x.Parent > 0 {
30 cmmts.WriteString(` reply`)
31 }
32 cmmts.WriteString(`"><p id="`)
33 cmmts.WriteString(strconv.Itoa(x.ID))
34 cmmts.WriteString(`"><img src="`)
35 cmmts.WriteString(URLBuilder(s.Host, "media", "emojitar", x.User.Username, "?type=a"))
36 cmmts.WriteString(`" width="30px" height="30px"><a href="`)
37 cmmts.WriteString(URLBuilder(s.Host, "group_user", "?q=", x.User.Username, "&type=a"))
38 cmmts.WriteString(`"><b`)
39 cmmts.WriteString(` class="`)
40 if x.User.Banned {
41 cmmts.WriteString(`banned`)
42 }
43 if x.Author {
44 cmmts.WriteString(`author`)
45 }
46 cmmts.WriteString(`">`)
47 cmmts.WriteString(x.User.Username)
48 cmmts.WriteString("</b></a> ")
49
50 if x.Parent > 0 {
51 cmmts.WriteString(` In reply to <a href="`)
52 cmmts.WriteString(s._pth)
53 cmmts.WriteString("#")
54 cmmts.WriteString(strconv.Itoa(x.Parent))
55 cmmts.WriteString(`">`)
56 if replied[x.Parent] == "" {
57 cmmts.WriteString("???")
58 } else {
59 cmmts.WriteString(replied[x.Parent])
60 }
61 cmmts.WriteString("</a>")
62 }
63 cmmts.WriteString(" [")
64 cmmts.WriteString(x.Posted.UTC().String())
65 cmmts.WriteString("]<p>")
66
67 cmmts.WriteString(ParseDescription(s.Host, x.TextContent))
68 cmmts.WriteString("<p>👍: ")
69 cmmts.WriteString(strconv.Itoa(x.Likes))
70 cmmts.WriteString(" ⏩: ")
71 cmmts.WriteString(strconv.Itoa(x.Replies))
72 cmmts.WriteString("</p></div>\n")
73 }
74 cmmts.WriteString(s.NavBase(DeviationList{
75 Pages: 0,
76 More: c.HasMore,
77 }))
78 cmmts.WriteString("</details>")
79 return cmmts.String()
80}
81
82// DeviationList renders devs as an HTML grid, or as an Atom feed when the
83// request asked for one and allowAtom permits it. NSFW entries are dropped
84// unless the instance allows them. Passing content adds a navigation bar.
85// VisibleDeviation reports whether a deviation may be shown by this instance.
86//
87// Both the HTML listing and the JSON API ask this, deliberately: an API that
88// returned what the pages hide would leak exactly what hide-ai and the NSFW
89// setting exist to withhold, and a second copy of the rule is a second thing to
90// forget to update.
91func VisibleDeviation(d *devianter.Deviation) bool {
92 if d.AI && CFG.HideAI {
93 return false
94 }
95 return !d.NSFW || CFG.Nsfw
96}
97
98func (s skunkyart) DeviationList(devs []devianter.Deviation, allowAtom bool, content ...DeviationList) string {
99 if s.Atom && s.Page > 1 {
100 s.ReturnHTTPError(400)
101 return ""
102 }
103
104 var list, listContent strings.Builder
105
106 for i, l := 0, len(devs); i < l; i++ {
107 data := &devs[i]
108 if !VisibleDeviation(data) {
109 continue
110 }
111 if preview, fullview := ParseMedia(s.Host, data.Media, 320), ParseMedia(s.Host, data.Media); true {
112 if allowAtom && s.Atom {
113 s.Writer.Header().Add("Content-Type", "application/atom+xml")
114 id := strconv.Itoa(data.ID)
115 listContent.WriteString(`<entry><author><name>`)
116 listContent.WriteString(data.Author.Username)
117 listContent.WriteString(`</name></author><title>`)
118 listContent.WriteString(data.Title)
119 listContent.WriteString(`</title><link rel="alternate" type="text/html" href="`)
120 listContent.WriteString(URLBuilder(s.Host, "post", data.Author.Username, "atom-"+id))
121 listContent.WriteString(`"/><id>`)
122 listContent.WriteString(id)
123 listContent.WriteString(`</id><published>`)
124 listContent.WriteString(data.PublishedTime.UTC().Format("Mon, 02 Jan 2006 15:04:05 -0700"))
125 listContent.WriteString(`</published>`)
126 listContent.WriteString(`<media:group><media:title>`)
127 listContent.WriteString(data.Title)
128 listContent.WriteString(`</media:title><media:thumbinal url="`)
129 listContent.WriteString(preview)
130 listContent.WriteString(`"/></media:group><content type="xhtml"><div xmlns="http://www.w3.org/1999/xhtml"><a href="`)
131 listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(s.Host, data.Url))
132 listContent.WriteString(`"><img src="`)
133 listContent.WriteString(fullview)
134 listContent.WriteString(`"/></a><p>`)
135 listContent.WriteString(ParseDescription(s.Host, data.TextContent))
136 listContent.WriteString(`</p></div></content></entry>`)
137 } else {
138 listContent.WriteString(`<div class="block">`)
139 if fullview != "" && preview != "" {
140 listContent.WriteString(`<a title="open/download" href="`)
141 listContent.WriteString(fullview)
142 listContent.WriteString(`"><img loading="lazy" src="`)
143 listContent.WriteString(preview)
144 listContent.WriteString(`" width="15%"></a>`)
145 } else {
146 listContent.WriteString(`<h1>[ TEXT ]</h1>`)
147 }
148 listContent.WriteString(`<br><a href="`)
149 listContent.WriteString(ConvertDeviantArtURLToSkunkyArt(s.Host, data.Url))
150 listContent.WriteString(`">`)
151 listContent.WriteString(data.Author.Username)
152 listContent.WriteString(" - ")
153 listContent.WriteString(data.Title)
154
155 if data.NSFW {
156 listContent.WriteString(` [<span class="nsfw">NSFW</span>]`)
157 }
158 if data.AI {
159 listContent.WriteString(" [🤖]")
160 }
161 if data.DD {
162 listContent.WriteString(` [<span class="dd">DD</span>]`)
163 }
164
165 listContent.WriteString("</a></div>")
166 }
167 }
168 }
169
170 if allowAtom && s.Atom {
171 list.WriteString(`<?xml version="1.0" encoding="UTF-8"?><feed xmlns:media="http://search.yahoo.com/mrss/" xmlns="http://www.w3.org/2005/Atom">`)
172
173 list.WriteString(`<title>`)
174 switch {
175 case s.Type == 0:
176 list.WriteString("Daily Deviations")
177 case s.Type == 'g' && len(devs) != 0:
178 list.WriteString(devs[0].Author.Username)
179 default:
180 list.WriteString("SkunkyArt")
181 }
182 list.WriteString(`</title>`)
183
184 list.WriteString(`<link rel="alternate" href="`)
185 list.WriteString(s.Host)
186 list.WriteString(`"/>`)
187
188 list.WriteString(listContent.String())
189
190 list.WriteString("</feed>")
191 wr(s.Writer, list.String())
192 } else {
193 list.WriteString(`<div class="content">`)
194
195 list.WriteString(listContent.String())
196
197 list.WriteString("</div>")
198 if content != nil {
199 list.WriteString(s.NavBase(content[0]))
200 }
201 }
202
203 return list.String()
204}
205
206/* DESCRIPTION/COMMENT PARSER */
207
208// text is one styled run within a description: the rendered HTML, the raw source
209// it came from, and the offsets it spans in the original block.
210type text struct {
211 Txt string
212 TxtRaw string
213 From int
214 To int
215}
216
217// ParseDescription renders a DeviantArt description into HTML, handling both the
218// Draft.js-style JSON payload and the plain HTML markup DeviantArt returns, and
219// rewriting embedded links and artwork references to point at this instance.
220// host is the request's scheme and host, as taken by URLBuilder.
221//
222// TODO: rewrite this whole mess.
223func ParseDescription(host string, dscr devianter.Text) string {
224 var parsedDescription strings.Builder
225 TagBuilder := func(content string, tags ...string) string {
226 l := len(tags)
227 for x := range l {
228 var htm strings.Builder
229 htm.WriteString("<")
230 htm.WriteString(tags[x])
231 htm.WriteString(">")
232
233 htm.WriteString(content)
234
235 htm.WriteString("</")
236 htm.WriteString(tags[x])
237 htm.WriteString(">")
238 content = htm.String()
239 }
240 return content
241 }
242 DeleteTrackingFromURL := func(url string) string {
243 if len(url) > 42 && url[:42] == "https://www.deviantart.com/users/outgoing?" {
244 url = url[42:]
245 }
246 return url
247 }
248
249 if description, dl := dscr.Html.Markup, len(dscr.Html.Markup); dl != 0 &&
250 description[0] == '{' &&
251 description[dl-1] == '}' {
252 var descr struct {
253 Blocks []struct {
254 Text string `json:"text"`
255 Type string `json:"type"`
256 InlineStyleRanges []struct {
257 Offset int `json:"offset"`
258 Length int `json:"length"`
259 Style string `json:"style"`
260 } `json:"inlineStyleRanges"`
261 EntityRanges []struct {
262 Offset int `json:"offset"`
263 Length int `json:"length"`
264 Key int `json:"key"`
265 } `json:"entityRanges"`
266 Data struct {
267 TextAlignment string `json:"textAlignment"`
268 } `json:"data"`
269 } `json:"blocks"`
270 EntityMap map[string]struct {
271 Type string `json:"type"`
272 Data struct {
273 URL string `json:"url"`
274 Config struct {
275 // "aligment" is DeviantArt's own spelling; do not correct it.
276 Aligment string `json:"aligment"`
277 Width int `json:"width"`
278 } `json:"config"`
279 Data devianter.Deviation `json:"data"`
280 } `json:"data"`
281 } `json:"entityMap"`
282 }
283 e := json.Unmarshal([]byte(description), &descr)
284 try(e)
285
286 entities := make(map[int]devianter.Deviation)
287 urls := make(map[int]string)
288 for n, x := range descr.EntityMap {
289 num, _ := strconv.Atoi(n)
290 if x.Data.URL != "" {
291 urls[num] = DeleteTrackingFromURL(x.Data.URL)
292 }
293 entities[num] = x.Data.Data
294 }
295
296 for _, x := range descr.Blocks {
297 Styles := make([]text, len(x.InlineStyleRanges))
298
299 if len(x.InlineStyleRanges) != 0 {
300 var tags = make(map[int][]string)
301 for n, rngs := range x.InlineStyleRanges {
302 Styles := &Styles[n]
303 switch rngs.Style {
304 case "BOLD":
305 rngs.Style = "b"
306 case "UNDERLINE":
307 rngs.Style = "u"
308 case "ITALIC":
309 rngs.Style = "i"
310 }
311 Styles.From = rngs.Offset
312 Styles.To = rngs.Offset + rngs.Length
313 FT := Styles.From * Styles.To
314 tags[FT] = append(tags[FT], rngs.Style)
315 }
316 for n := range Styles {
317 Styles := &Styles[n]
318 Styles.TxtRaw = x.Text[Styles.From:Styles.To]
319 Styles.Txt = TagBuilder(Styles.TxtRaw, tags[Styles.From*Styles.To]...)
320 }
321 }
322
323 switch x.Type {
324 case "atomic":
325 if len(x.EntityRanges) != 0 {
326 d := entities[x.EntityRanges[0].Key]
327 parsedDescription.WriteString(`<a href="`)
328 parsedDescription.WriteString(ConvertDeviantArtURLToSkunkyArt(host, d.Url))
329 parsedDescription.WriteString(`"><img width="50%" src="`)
330 parsedDescription.WriteString(ParseMedia(host, d.Media))
331 parsedDescription.WriteString(`" title="`)
332 parsedDescription.WriteString(d.Author.Username)
333 parsedDescription.WriteString(" - ")
334 parsedDescription.WriteString(d.Title)
335 parsedDescription.WriteString(`"></a>`)
336 }
337 case "unstyled":
338 if l := len(Styles); l != 0 {
339 for n, r := range Styles {
340 var tag string
341 if x.Type == "header-two" {
342 tag = "h2"
343 }
344
345 parsedDescription.WriteString(x.Text[:r.From])
346 if len(urls) != 0 && len(x.EntityRanges) != 0 {
347 ra := &x.EntityRanges[0]
348
349 parsedDescription.WriteString(`<a target="_blank" href="`)
350 parsedDescription.WriteString(urls[ra.Key])
351 parsedDescription.WriteString(`">`)
352 parsedDescription.WriteString(r.Txt)
353 parsedDescription.WriteString(`</a>`)
354 } else if l > n+1 {
355 parsedDescription.WriteString(r.Txt)
356 }
357 parsedDescription.WriteString(TagBuilder(tag, x.Text[r.To:]))
358 }
359 } else {
360 parsedDescription.WriteString(x.Text)
361 }
362 }
363 parsedDescription.WriteString("<br>")
364 }
365 } else if dl != 0 {
366 for tt := html.NewTokenizer(strings.NewReader(dscr.Html.Markup)); ; {
367 switch tt.Next() {
368 case html.ErrorToken:
369 return parsedDescription.String()
370 case html.CommentToken, html.DoctypeToken:
371 // No renderable content; skip.
372 case html.StartTagToken, html.EndTagToken, html.SelfClosingTagToken:
373 token := tt.Token()
374 switch token.Data {
375 case "a":
376 for _, a := range token.Attr {
377 if a.Key == "href" {
378 url := DeleteTrackingFromURL(a.Val)
379 parsedDescription.WriteString(`<a target="_blank" href="`)
380 parsedDescription.WriteString(url)
381 parsedDescription.WriteString(`">`)
382 parsedDescription.WriteString(GetValueOfTag(tt))
383 parsedDescription.WriteString("</a> ")
384 }
385 }
386 case "img":
387 var uri, title string
388 for b, a := range token.Attr {
389 switch a.Key {
390 case "src":
391 if len(a.Val) > 9 && a.Val[8:9] == "e" {
392 uri = URLBuilder(host, "media", "emojitar", a.Val[37:len(a.Val)-4], "?type=e")
393 }
394 case "title":
395 title = a.Val
396 }
397 if title != "" {
398 for x := -1; x < b; x++ {
399 parsedDescription.WriteString(`<img src="`)
400 parsedDescription.WriteString(uri)
401 parsedDescription.WriteString(`" title="`)
402 parsedDescription.WriteString(title)
403 parsedDescription.WriteString(`">`)
404 }
405 }
406 }
407 case "br", "li", "ul", "p", "b":
408 parsedDescription.WriteString(token.String())
409 case "div":
410 parsedDescription.WriteString("<p> ")
411 }
412 case html.TextToken:
413 parsedDescription.Write(tt.Text())
414 }
415 }
416 }
417
418 return parsedDescription.String()
419}