app/wrapper.go
423 lines · 13558 bytes · executable
1package app
2
3import (
4 "crypto/sha1" //nolint:gosec // G505: SHA-1 is a cache-key hash here, not a security primitive
5 "html/template"
6 "maps"
7 "net/url"
8 "regexp"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/krazywarez/devianter"
14 "golang.org/x/net/html"
15)
16
17// devianter calls behind variables so tests can count and script them.
18var (
19 fetchDeviation = devianter.GetDeviation
20 fetchComments = devianter.GetComments
21 fetchProfile = func(name string) (devianter.GRuser, devianter.Error, error) {
22 g := devianter.Group{Name: name}
23 return g.Get()
24 }
25)
26
27// groupSearchURL is the DeviantArt group search page for query, paged ten
28// results at a time. page counts from 1; 0 means the first page too.
29func groupSearchURL(query string, page int) string {
30 var url strings.Builder
31 url.WriteString("https://www.deviantart.com/groups/?q=")
32 url.WriteString(query)
33 if page > 1 {
34 url.WriteString("&offset=")
35 url.WriteString(strconv.Itoa(10 * (page - 1)))
36 }
37 return url.String()
38}
39
40// commentsOrLink renders a comment thread only when the request asked for it
41// with ?comments=1, and otherwise a link that does. A thread is a second
42// upstream call on every post and profile view, and most viewers never open
43// it. total is shown in the link when it is known (0 or more).
44func (s skunkyart) commentsOrLink(id, cursor string, kind, total int) template.HTML {
45 if s.Args.Get("comments") != "" {
46 return template.HTML(s.ParseComments(fetchComments(id, cursor, s.Page, kind))) //nolint:gosec // G203: ParseComments escapes its input
47 }
48
49 args := url.Values{}
50 maps.Copy(args, s.Args)
51 args.Del("p")
52 args.Set("comments", "1")
53
54 var link strings.Builder
55 link.WriteString(`<p><a href="`)
56 link.WriteString(esc(s._pth + "?" + args.Encode()))
57 link.WriteString(`">`)
58 link.WriteString(esc(T(s.Lang, "deviation.comments")))
59 if total >= 0 {
60 link.WriteString(" (")
61 link.WriteString(strconv.Itoa(total))
62 link.WriteString(")")
63 }
64 link.WriteString("</a></p>")
65 return template.HTML(link.String()) //nolint:gosec // G203: escaped above
66}
67
68// GRUser renders a group or user page: the about tab, the gallery, or favourites,
69// selected by the request's type argument.
70func (s skunkyart) GRUser() {
71 if len(s.Query) < 1 {
72 s.ReturnHTTPError(400)
73 return
74 }
75
76 var g devianter.Group
77 var daError devianter.Error
78 g.Name = s.Query
79 var err error
80 s.Templates.GroupUser.GR, daError, err = fetchProfile(s.Query)
81 try(err)
82 if daError.RAW != nil {
83 s.Error(daError)
84 return
85 }
86
87 group := &s.Templates.GroupUser
88
89 switch s.Type {
90 case 'a':
91 g := group.GR
92 s.Atom = false
93 for _, x := range g.Gruser.Page.Modules {
94 switch x.Name {
95 case "about", "group_about":
96 if g.Owner.Group {
97 var about = &x.ModuleData.GroupAbout
98 group.Group = true
99 group.CreationDate = x.ModuleData.GroupAbout.FoundatedAt.UTC().String()
100 group.About.DescriptionFormatted = template.HTML(ParseDescription(s.Host, about.Description)) //nolint:gosec // G203: ParseDescription escapes its input
101 } else {
102 group.About.A = x.ModuleData.About
103 var about = &group.About.A
104 group.CreationDate = time.Unix(time.Now().Unix()-x.ModuleData.About.RegDate, 0).UTC().String()
105 group.About.DescriptionFormatted = template.HTML(ParseDescription(s.Host, about.Description)) //nolint:gosec // G203: ParseDescription escapes its input
106
107 for _, val := range x.ModuleData.About.SocialLinks {
108 var social strings.Builder
109 social.WriteString(`<a target="_blank" href="`)
110 social.WriteString(esc(val.Value))
111 social.WriteString(`">`)
112 social.WriteString(esc(val.Value))
113 social.WriteString("</a><br>")
114 group.About.Social += template.HTML(social.String()) //nolint:gosec // G203: escaped above
115 }
116
117 for _, val := range x.ModuleData.About.Interests {
118 var interest strings.Builder
119 interest.WriteString(esc(val.Label))
120 interest.WriteString(": <b>")
121 interest.WriteString(esc(val.Value))
122 interest.WriteString("</b><br>")
123 group.About.Interests += template.HTML(interest.String()) //nolint:gosec // G203: escaped above
124 }
125 }
126 group.About.Comments = s.commentsOrLink(strconv.Itoa(group.GR.Gruser.ID), "", 4, -1)
127
128 case "cover_deviation":
129 group.About.BGMeta = x.ModuleData.CoverDeviation.Deviation
130 group.About.BGMeta.Url = ConvertDeviantArtURLToSkunkyArt(s.Host, group.About.BGMeta.Url)
131 group.About.BG = ParseMedia(s.Host, group.About.BGMeta.Media)
132 case "group_admins":
133 var htm strings.Builder
134 for _, z := range x.ModuleData.GroupAdmins.Results {
135 htm.WriteString(BuildUserPlate(s.Host, z.User.Username))
136 }
137 group.Admins += template.HTML(htm.String()) //nolint:gosec // G203: BuildUserPlate escapes its input
138 }
139
140 }
141 case 'g', 'f':
142 var all bool
143 var content devianter.Group
144
145 folderid, _ := strconv.Atoi(s.Args.Get("folder"))
146
147 if s.Args.Get("all") == "true" {
148 all = true
149 }
150
151 if s.Page == 0 {
152 s.Page++
153 }
154
155 if s.Type == 'f' {
156 content, daError = g.Favourites(s.Page, all, folderid)
157 } else {
158 content, daError, err = g.Gallery(s.Page, folderid)
159 try(err)
160 }
161
162 if daError.RAW != nil {
163 s.Error(daError)
164 return
165 }
166
167 if folderid > 0 || (s.Type == 'f' && all) {
168 group.Gallery.List = template.HTML(s.DeviationList(content.Content.Results, true, DeviationList{ //nolint:gosec // G203: DeviationList escapes its input
169 More: content.Content.HasMore,
170 }))
171 } else {
172 for _, x := range content.Content.Gruser.Page.Modules {
173 if len(x.ModuleData.Folders.Results) != 0 {
174 var folders strings.Builder
175 folders.WriteString(`<h1 id="folders"><a href="#folders">#</a> ` + esc(T(s.Lang, "gallery.folders")) + `</h1><div class="folders"><br>`)
176 for _, x := range x.ModuleData.Folders.Results {
177 if x.FolderId != -1 && x.Size != 0 {
178 folders.WriteString(`<div class="block folder-item">`)
179
180 if !x.Thumb.NSFW || CFG.Nsfw {
181 folders.WriteString(`<a href="`)
182 folders.WriteString(esc(ConvertDeviantArtURLToSkunkyArt(s.Host, x.Thumb.Url)))
183 folders.WriteString(`"><img loading="lazy" src="`)
184 folders.WriteString(esc(ParseMedia(s.Host, x.Thumb.Media)))
185 folders.WriteString(`" title="`)
186 folders.WriteString(esc(x.Thumb.Title))
187 folders.WriteString(`" alt="`)
188 folders.WriteString(esc(x.Thumb.Title))
189 folders.WriteString(`"></a>`)
190 } else {
191 folders.WriteString(`<h1>[ <span class="nsfw">NSFW</span> ]</h1>`)
192 }
193 folders.WriteString("<br>")
194
195 folders.WriteString(`<a href="group_user?folder=`)
196 folders.WriteString(strconv.Itoa(x.FolderId))
197 folders.WriteString("&q=")
198 folders.WriteString(esc(s.Query))
199 folders.WriteString("&type=")
200 folders.WriteRune(s.Type)
201 folders.WriteString(`">`)
202 folders.WriteString(esc(x.Name))
203 folders.WriteString(`</a>`)
204
205 folders.WriteString("</div>")
206 }
207 }
208 folders.WriteString(`</div><h1 id="content"><a href="#content">#</a> ` + esc(T(s.Lang, "gallery.content")) + `</h1>`)
209 group.Gallery.Folders = template.HTML(folders.String()) //nolint:gosec // G203: escaped above
210 }
211
212 if x.Name == "folder_deviations" {
213 group.Gallery.List = template.HTML(s.DeviationList(x.ModuleData.Folder.Deviations, true, DeviationList{ //nolint:gosec // G203: DeviationList escapes its input
214 Pages: x.ModuleData.Folder.Pages,
215 More: x.ModuleData.Folder.HasMore,
216 }))
217 }
218 }
219 }
220 default:
221 s.ReturnHTTPError(400)
222 }
223
224 if !s.Atom {
225 s.ExecuteTemplate("gruser.htm", "html", &s)
226 }
227}
228
229// Deviation renders a single artwork page, with its description, tags, comments
230// and related work. It responds 403 for NSFW posts on instances that disallow them.
231func (s skunkyart) Deviation(author, postname string) {
232 idSearch := regexp.MustCompile("[0-9]+").FindAllString(postname, -1)
233 if len(idSearch) < 1 {
234 s.ReturnHTTPError(400)
235 return
236 }
237
238 var err devianter.Error
239 post := &s.Templates.Deviation
240
241 id := idSearch[len(idSearch)-1]
242 post.Post, err = fetchDeviation(id, author)
243 if err.RAW != nil {
244 s.Error(err)
245 return
246 }
247
248 if post.Post.Deviation.NSFW && !CFG.Nsfw {
249 s.Writer.Header().Del("Cache-Control")
250 s.Writer.WriteHeader(403)
251 wr(s.Writer, `<html><link rel="stylesheet" href="`+
252 URLBuilder(s.Host, "stylesheet")+
253 `" /><h1>`+esc(T(s.Lang, "error.nsfw"))+`</h1></html>`)
254 return
255 }
256
257 if post.Post.Comments.Total <= 50 {
258 post.Post.Comments.Cursor = ""
259 }
260
261 if post.Post.Deviation.TextContent.Excerpt != "" {
262 post.Description = template.HTML(ParseDescription(s.Host, post.Post.Deviation.TextContent)) //nolint:gosec // G203: ParseDescription escapes its input
263 } else {
264 post.Description = template.HTML(ParseDescription(s.Host, post.Post.Deviation.Extended.DescriptionText)) //nolint:gosec // G203: ParseDescription escapes its input
265 }
266
267 for _, x := range post.Post.Deviation.Extended.RelatedContent {
268 if len(x.Deviations) != 0 {
269 post.Related += template.HTML(s.DeviationList(x.Deviations, false)) //nolint:gosec // G203: DeviationList escapes its input
270 }
271 }
272
273 // hashtags
274 for _, x := range post.Post.Deviation.Extended.Tags {
275 var tag strings.Builder
276 tag.WriteString(` <a href="`)
277 tag.WriteString(esc(URLBuilder(s.Host, "search", "?q=", x.Name, "&type=tag")))
278 tag.WriteString(`">#`)
279 tag.WriteString(esc(x.Name))
280 tag.WriteString("</a>")
281
282 post.Tags += template.HTML(tag.String()) //nolint:gosec // G203: escaped above
283 }
284
285 post.Comments = s.commentsOrLink(id, post.Post.Comments.Cursor, 1, post.Post.Comments.Total)
286 post.StringTime = post.Post.Deviation.PublishedTime.UTC().String()
287 post.Post.IMG = ParseMedia(s.Host, post.Post.Deviation.Media)
288
289 s.ExecuteTemplate("deviantion.htm", "html", &s)
290}
291
292// DD renders the Daily Deviations page, including each themed strip.
293func (s skunkyart) DD() {
294 dd, err := devianter.GetDailyDeviations(s.Page)
295 if err.RAW != nil {
296 s.Error(err)
297 return
298 }
299 var strips strings.Builder
300 for _, x := range dd.Strips {
301 strips.WriteString(`<h3 class="`)
302 strips.WriteString(esc(x.Codename))
303 strips.WriteString(`"> <a href="#`)
304 strips.WriteString(esc(x.Codename))
305 strips.WriteString(`"># </a>`)
306 strips.WriteString(esc(x.Title))
307 strips.WriteString(`</h3>`)
308
309 strips.WriteString(s.DeviationList(x.Deviations, false))
310 }
311 s.Templates.DDStrips = template.HTML(strips.String()) //nolint:gosec // G203: escaped above
312 s.Templates.SomeList = template.HTML(s.DeviationList(dd.Deviations, true, DeviationList{ //nolint:gosec // G203: DeviationList escapes its input
313 Pages: 0,
314 More: dd.HasMore,
315 }))
316 if !s.Atom {
317 s.ExecuteTemplate("daily.htm", "html", &s)
318 }
319}
320
321// Search renders search results for the request's query. Group search is scraped
322// rather than fetched from the API, which DeviantArt does not expose to guests.
323func (s skunkyart) Search() {
324 if s.Query == "" {
325 s.ReturnHTTPError(400)
326 return
327 }
328
329 var err error
330 var daError devianter.Error
331 ss := &s.Templates.Search
332 switch s.Type {
333 case 'a', 't':
334 ss.Content, daError, err = devianter.PerformSearch(s.Query, s.Page, s.Type)
335 case 'g', 'f':
336 ss.Content, daError, err = devianter.PerformSearch(s.Query, s.Page, s.Type, s.Args.Get("usr"))
337 case 'r': // scraper, since DeviantArt withholds the guest API for group search
338 var (
339 usernames = make(map[int]string)
340 num int
341 )
342
343 dwnld := Download(groupSearchURL(s.Query, s.Page))
344
345 for z := html.NewTokenizer(strings.NewReader(string(dwnld.Body))); ; {
346 if n, token := z.Next(), z.Token(); n == html.StartTagToken && token.Data == "a" {
347 for _, x := range token.Attr {
348 if x.Key == "class" && x.Val == "u regular username" {
349 usernames[num] = GetValueOfTag(z)
350 num++
351 }
352 }
353 } else if n == 0 {
354 break
355 } else {
356 continue
357 }
358 }
359
360 if len(usernames) != 0 {
361 var plates strings.Builder
362 plates.WriteString(`<div class="content plates">`)
363 for x := range len(usernames) {
364 plates.WriteString(BuildUserPlate(s.Host, usernames[x]))
365 }
366 plates.WriteString(`</div>`)
367 plates.WriteString(s.NavBase(DeviationList{
368 More: true,
369 }))
370 ss.List = template.HTML(plates.String()) //nolint:gosec // G203: BuildUserPlate escapes its input
371 }
372 default:
373 s.ReturnHTTPError(400)
374 return
375 }
376 try(err)
377
378 if s.Type != 'r' {
379 if daError.RAW != nil {
380 s.Error(daError)
381 return
382 }
383
384 ss.List = template.HTML(s.DeviationList(ss.Content.Results, false, DeviationList{ //nolint:gosec // G203: DeviationList escapes its input
385 Pages: ss.Content.Pages,
386 More: ss.Content.HasMore,
387 }))
388 }
389
390 s.ExecuteTemplate("search.htm", "html", &s)
391}
392
393// fetchAvatar is devianter.AEmedia behind a variable so tests can count calls.
394var fetchAvatar = devianter.AEmedia
395
396// Emojitar proxies a user's avatar or emoji image, selected by the request's
397// type argument. With the media cache on, the image is served from it after
398// the first fetch: avatars are on every comment and listing, and DeviantArt
399// answers each fetch with up to three requests.
400func (s skunkyart) Emojitar(name string) {
401 if name == "" || (s.Type != 'a' && s.Type != 'e') {
402 s.ReturnHTTPError(400)
403 return
404 }
405
406 key := sha1.Sum([]byte("emojitar:" + string(s.Type) + ":" + strings.ToLower(name))) //nolint:gosec // G401: cache key, not a security primitive
407 if CFG.Cache.Enabled {
408 if body := cachedBody(key); body != nil {
409 _, _ = s.Writer.Write(body)
410 return
411 }
412 }
413
414 ae, e := fetchAvatar(name, s.Type)
415 if e != nil {
416 s.ReturnHTTPError(404)
417 return
418 }
419 if CFG.Cache.Enabled {
420 storeBody(key, []byte(ae))
421 }
422 wr(s.Writer, ae)
423}