app/wrapper.go

v1.5.5
skunky-art/app/wrapper.go history · blame · raw

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}