krz/devianter
A DeviantArt guest API library for Go.
clone: git clone https://gitbay.org/krz/devianter.git
v0.3.4: util.go · raw
1package devianter
2
3import (
4 "encoding/json"
5 "errors"
6 "io"
7 "net/http"
8 "strconv"
9 "strings"
10 "time"
11)
12
13// try prints a non-nil error to stderr and swallows it. It is how this package
14// reports problems it does not propagate, such as a response that parsed only
15// partially.
16func try(txt error) {
17 if txt != nil {
18 println(txt.Error())
19 }
20}
21
22// ujson fetches a _puppy endpoint and unmarshals the response into output.
23// data is the path and query string after the endpoint root, without a leading
24// slash and without the csrf_token parameter, which puppy appends.
25//
26// A malformed response is reported through try and leaves output partially
27// populated, so a returned Error with an empty Reason does not by itself
28// guarantee that output is complete.
29func ujson(data string, output any) Error {
30 input, err := puppy(data)
31 if err == nil {
32 try(json.Unmarshal([]byte(input), output))
33 }
34 return APIError(err)
35}
36
37// Error is a failed API call. It is a struct rather than an error interface, so
38// a zero value means success: test Reason for emptiness rather than comparing
39// against nil.
40//
41// For errors DeviantArt itself reports, Reason and Error hold its machine and
42// human readable descriptions. For anything else (a transport failure, or a
43// CloudFront block page) Reason is "request_failed" and Error carries the
44// underlying message.
45type Error struct {
46 Reason string `json:"error"`
47 Error string `json:"errorDescription"`
48 RAW []byte `json:"-"`
49}
50
51// APIError converts an error from the request layer into an [Error], decoding
52// DeviantArt's JSON error body when that is what it is. A nil input yields the
53// zero Error, which signals success.
54func APIError(inputError error) (err Error) {
55 if inputError != nil {
56 err.RAW = []byte(inputError.Error())
57 // DA's API errors are JSON. Anything else (CDN block pages, transport
58 // failures) is surfaced as-is rather than spamming a JSON parse error —
59 // this is what used to print `invalid character '<'` on every page.
60 if json.Unmarshal(err.RAW, &err) != nil {
61 err.Reason = "request_failed"
62 err.Error = inputError.Error()
63 }
64 }
65 return
66}
67
68/* REQUEST SECTION */
69// reqrt is a completed HTTP response, flattened into the pieces this package
70// needs. On a transport failure Err is set and every other field is zero.
71type reqrt struct {
72 Body string
73 Status int
74 Cookies []*http.Cookie
75 Headers http.Header
76 // Err is set when the request never completed (transport error). Status is 0.
77 Err error
78}
79
80// UserAgent overrides the browser User-Agent this package sends by default.
81// Setting it to something that identifies your client is polite, but DeviantArt
82// is more likely to serve a block page to a non-browser agent.
83var UserAgent string
84
85// Timeout bounds a single request end-to-end (dial, response, body read).
86// Without it, a hung connection blocks its caller forever.
87var Timeout = 30 * time.Second
88
89// request performs a GET and never panics or returns a partial response without
90// saying so: any failure is reported in reqrt.Err. An optional second argument
91// supplies the Cookie header.
92func request(uri string, other ...string) reqrt {
93 var r reqrt
94
95 // Transport is deliberately left nil so http.DefaultTransport applies: that
96 // keeps HTTPS_PROXY support and lets callers wrap it (e.g. to rate-limit).
97 cli := &http.Client{Timeout: Timeout}
98 req, e := http.NewRequest("GET", uri, nil)
99 if e != nil {
100 try(e)
101 r.Err = e
102 return r
103 }
104
105 // Impersonate a browser by default: the endpoints are the web frontend's own,
106 // and an unfamiliar agent draws a block page.
107 req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0.0")
108
109 if UserAgent != "" {
110 req.Header.Set("User-Agent", UserAgent)
111 }
112 if len(other) != 0 {
113 req.Header.Set("Cookie", other[0])
114 }
115
116 resp, e := cli.Do(req)
117 if e != nil {
118 // resp is nil on error: returning here avoids dereferencing it, which
119 // used to panic and (from UpdateCSRF's goroutine) kill the process.
120 try(e)
121 r.Err = e
122 return r
123 }
124 defer func() {
125 if err := resp.Body.Close(); err != nil {
126 try(err)
127 }
128 }()
129
130 body, e := io.ReadAll(resp.Body)
131 if e != nil {
132 try(e)
133 r.Err = e
134 }
135
136 r.Body = string(body)
137 r.Cookies = resp.Cookies()
138 r.Headers = resp.Header
139 r.Status = resp.StatusCode
140
141 return r
142}
143
144// looksLikeJSON reports whether a response is actually JSON, so an HTML page from
145// a CDN/edge never reaches json.Unmarshal.
146func looksLikeJSON(r reqrt) bool {
147 if ct := r.Headers.Get("Content-Type"); ct != "" && !strings.Contains(ct, "json") {
148 return false
149 }
150 b := strings.TrimSpace(r.Body)
151 return len(b) > 0 && (b[0] == '{' || b[0] == '[')
152}
153
154// describe renders a failed response as a readable message, instead of the opaque
155// `invalid character '<'` you get from json.Unmarshal on an HTML error page.
156func describe(r reqrt) string {
157 body := strings.TrimSpace(r.Body)
158 if looksLikeJSON(r) {
159 return body // DA's own JSON error; callers unmarshal it into Error
160 }
161
162 msg := "devianter: HTTP " + strconv.Itoa(r.Status) + " non-JSON response from DeviantArt"
163 if strings.Contains(body, "Generated by cloudfront") || strings.Contains(body, "Request blocked") {
164 msg += ": blocked by CloudFront/WAF — this egress IP is likely banned"
165 }
166 if len(body) > 200 {
167 body = body[:200] + "..."
168 }
169 return msg + " — " + body
170}
171
172/* PUPPY aka DeviantArt API */
173// The guest session: a cookie from the _puppy endpoint and a CSRF token scraped
174// from the homepage. UpdateCSRF populates both; puppy sends them on every call.
175//
176// These are package-level and unsynchronised, so a program that calls UpdateCSRF
177// concurrently with any other function of this package races on them.
178var cookie string
179var token string
180
181const (
182 csrfPrefix = "window.__CSRF_TOKEN__ = '"
183 xhrMarker = "window.__XHR_LOCAL__"
184)
185
186// UpdateCSRF establishes the guest session that every other call in this package
187// depends on, and must be called before them. It fetches a session cookie (only
188// on the first call; later calls reuse it) and scrapes a fresh CSRF token from
189// the DeviantArt homepage.
190//
191// Tokens expire, so a long-running program should call this again when requests
192// begin to fail. It is not safe to call concurrently with other functions of
193// this package.
194//
195// An error means the session was not established: the homepage was blocked,
196// served a challenge, or changed its markup such that the token is no longer
197// where this package looks for it.
198func UpdateCSRF() error {
199 if cookie == "" {
200 req := request("https://www.deviantart.com/_puppy")
201
202 for _, content := range req.Cookies {
203 cookie = content.Raw
204 }
205 }
206
207 req := request("https://www.deviantart.com", cookie)
208 if req.Err != nil {
209 return req.Err
210 }
211 if req.Status != 200 {
212 return errors.New(describe(req))
213 }
214
215 // Bounds-check the markers. On a block/challenge page they are absent, and the
216 // old arithmetic sliced Body[24:-4] — a panic that killed the whole process.
217 start, end := strings.Index(req.Body, csrfPrefix), strings.Index(req.Body, xhrMarker)
218 if start < 0 || end < 0 {
219 return errors.New("devianter: CSRF token not found in homepage (blocked, challenged, or markup changed)")
220 }
221 start += len(csrfPrefix)
222 end -= 3
223 if end <= start || end > len(req.Body) {
224 return errors.New("devianter: CSRF token markers out of order (markup changed)")
225 }
226 token = req.Body[start:end]
227
228 return nil
229}
230
231// puppy calls a _puppy endpoint with the guest session applied and returns the
232// raw JSON body. data is a path and query string; the CSRF token and API version
233// are appended to it, so it must already end in a parameter (callers conclude
234// theirs with a trailing "&" or a final value).
235//
236// It returns an error for a transport failure, a non-200 status, or a 200 whose
237// body is not JSON, which is how a CDN block page arrives.
238func puppy(data string) (string, error) {
239 var url strings.Builder
240 url.WriteString("https://www.deviantart.com/_puppy/")
241 url.WriteString(data)
242 url.WriteString("&csrf_token=")
243 url.WriteString(token)
244 url.WriteString("&da_minor_version=20230710")
245
246 body := request(url.String(), cookie)
247 if body.Err != nil {
248 return "", body.Err
249 }
250
251 if body.Status != 200 {
252 return "", errors.New(describe(body))
253 }
254
255 // A 200 that isn't JSON means an edge/CDN page slipped through.
256 if !looksLikeJSON(body) {
257 return "", errors.New(describe(body))
258 }
259
260 return body.Body, nil
261}