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 "regexp"
6
7 "github.com/krazywarez/devianter"
8)
9
10// The API deliberately serves its own shapes rather than devianter's structs.
11// Those describe DeviantArt's payloads and change when DeviantArt changes; an
12// instance's consumers should not have to.
13type apiDeviation struct {
14 ID int `json:"id"`
15 Title string `json:"title"`
16 Author string `json:"author"`
17 URL string `json:"url"`
18 Published string `json:"published,omitempty"`
19 NSFW bool `json:"nsfw"`
20 AI bool `json:"ai"`
21 DailyDev bool `json:"daily_deviation"`
22 Tags []string `json:"tags,omitempty"`
23 Preview string `json:"preview,omitempty"`
24 Fullview string `json:"fullview,omitempty"`
25 Favourite int `json:"favourites"`
26 Views int `json:"views"`
27}
28
29type apiSearchResponse struct {
30 Query string `json:"query"`
31 Type string `json:"type"`
32 Page int `json:"page"`
33 Results []apiDeviation `json:"results"`
34}
35
36// toAPIDeviation flattens one deviation. Media URLs are routed back through this
37// instance when proxying is on, so a consumer never has to talk to wixmp itself
38// — the same indirection the HTML pages use.
39func (s skunkyart) toAPIDeviation(d *devianter.Deviation) apiDeviation {
40 out := apiDeviation{
41 ID: d.ID,
42 Title: d.Title,
43 Author: d.Author.Username,
44 URL: ConvertDeviantArtURLToSkunkyArt(s.Host, d.Url),
45 NSFW: d.NSFW,
46 AI: d.AI,
47 DailyDev: d.DD,
48 Preview: ParseMedia(s.Host, d.Media, 320),
49 Fullview: ParseMedia(s.Host, d.Media),
50 Favourite: d.Stats.Favourites,
51 Views: d.Stats.Views,
52 }
53 if !d.PublishedTime.IsZero() {
54 out.Published = d.PublishedTime.UTC().Format("2006-01-02T15:04:05Z")
55 }
56 for _, t := range d.Extended.Tags {
57 out.Tags = append(out.Tags, t.Name)
58 }
59 return out
60}
61
62// writeJSON marshals v. A marshal failure is reported as a 500 rather than
63// sending a half-written body with a 200 already on the wire.
64func (a API) writeJSON(v any) {
65 body, err := json.Marshal(v)
66 if err != nil {
67 a.Error("failed to encode response", 500)
68 return
69 }
70 _, _ = a.main.Writer.Write(body)
71}
72
73// Search responds with the deviations matching ?q=, honouring the instance's
74// NSFW and hide-ai settings through VisibleDeviation — the same rule the HTML
75// listing applies.
76//
77// ?type= takes the same single letters the pages do: a (art, default),
78// t (text), g (gallery), f (favourites). Gallery and favourites need ?usr=.
79func (a API) Search() {
80 s := a.main
81 if s.Query == "" {
82 a.Error("missing required parameter: q", 400)
83 return
84 }
85
86 kind := s.Type
87 if kind == 0 {
88 kind = 'a'
89 }
90
91 var (
92 result devianter.Search
93 daError devianter.Error
94 err error
95 )
96 switch kind {
97 case 'a', 't':
98 result, daError, err = devianter.PerformSearch(s.Query, s.Page, kind)
99 case 'g', 'f':
100 usr := s.Args.Get("usr")
101 if usr == "" {
102 a.Error("type "+string(kind)+" requires the usr parameter", 400)
103 return
104 }
105 result, daError, err = devianter.PerformSearch(s.Query, s.Page, kind, usr)
106 default:
107 a.Error("unsupported type: "+string(kind), 400)
108 return
109 }
110
111 if err != nil {
112 a.Error("upstream request failed", 502)
113 return
114 }
115 if daError.RAW != nil {
116 a.Error("deviantart returned an error", 502)
117 return
118 }
119
120 // Non-nil so an empty page marshals as [] rather than null.
121 out := apiSearchResponse{
122 Query: s.Query,
123 Type: string(kind),
124 Page: s.Page,
125 Results: []apiDeviation{},
126 }
127 for i := range result.Results {
128 d := &result.Results[i]
129 if !VisibleDeviation(d) {
130 continue
131 }
132 out.Results = append(out.Results, s.toAPIDeviation(d))
133 }
134 a.writeJSON(out)
135}
136
137// Post responds with a single deviation. postname carries the numeric id the
138// way the HTML route does, e.g. "some-title-123456789".
139//
140// Gated on NSFW only, matching the page: hide-ai omits AI work from *listings*,
141// and a reader who has followed a direct link to one still gets it. Diverging
142// here would make the API disagree with the site it fronts.
143func (a API) Post(author, postname string) {
144 s := a.main
145 if author == "" || postname == "" {
146 a.Error("missing author or post name", 400)
147 return
148 }
149
150 idSearch := regexp.MustCompile("[0-9]+").FindAllString(postname, -1)
151 if len(idSearch) < 1 {
152 a.Error("post name carries no deviation id", 400)
153 return
154 }
155
156 post, daError := devianter.GetDeviation(idSearch[len(idSearch)-1], author)
157 if daError.RAW != nil {
158 a.Error("deviantart returned an error", 502)
159 return
160 }
161
162 d := &post.Deviation
163 if d.NSFW && !CFG.Nsfw {
164 a.Error("nsfw content is disabled on this instance", 403)
165 return
166 }
167
168 a.writeJSON(struct {
169 apiDeviation
170 Description string `json:"description,omitempty"`
171 Downloads int `json:"downloads"`
172 Filesize int `json:"filesize,omitempty"`
173 Width int `json:"width,omitempty"`
174 Height int `json:"height,omitempty"`
175 }{
176 apiDeviation: s.toAPIDeviation(d),
177 Description: ParseDescription(s.Host, d.Extended.DescriptionText),
178 Downloads: d.Stats.Downloads,
179 Filesize: d.Extended.OriginalFile.Filesize,
180 Width: d.Extended.OriginalFile.Width,
181 Height: d.Extended.OriginalFile.Height,
182 })
183}