krz/devianter
A DeviantArt guest API library for Go.
clone: git clone https://gitbay.org/krz/devianter.git
v0.3.0: 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// функция для высера ошибки в stderr
14func try(txt error) {
15 if txt != nil {
16 println(txt.Error())
17 }
18}
19
20func ujson(data string, output any) Error {
21 input, err := puppy(data)
22 if err == nil {
23 try(json.Unmarshal([]byte(input), output))
24 }
25 return APIError(err)
26}
27
28type Error struct {
29 Reason string `json:"error"`
30 Error string `json:"errorDescription"`
31 RAW []byte `json:"-"`
32}
33func APIError(inputError error) (err Error) {
34 if inputError != nil {
35 err.RAW = []byte(inputError.Error())
36 // DA's API errors are JSON. Anything else (CDN block pages, transport
37 // failures) is surfaced as-is rather than spamming a JSON parse error —
38 // this is what used to print `invalid character '<'` on every page.
39 if json.Unmarshal(err.RAW, &err) != nil {
40 err.Reason = "request_failed"
41 err.Error = inputError.Error()
42 }
43 }
44 return
45}
46
47/* REQUEST SECTION */
48// структура для ответа сервера
49type reqrt struct {
50 Body string
51 Status int
52 Cookies []*http.Cookie
53 Headers http.Header
54 // Err is set when the request never completed (transport error). Status is 0.
55 Err error
56}
57
58// функция для совершения запроса
59var UserAgent string
60
61// Timeout bounds a single request end-to-end (dial, response, body read).
62// Without it, a hung connection blocks its caller forever.
63var Timeout = 30 * time.Second
64
65func request(uri string, other ...string) reqrt {
66 var r reqrt
67
68 // создаём новый запрос
69 // Transport is deliberately left nil so http.DefaultTransport applies: that
70 // keeps HTTPS_PROXY support and lets callers wrap it (e.g. to rate-limit).
71 cli := &http.Client{Timeout: Timeout}
72 req, e := http.NewRequest("GET", uri, nil)
73 if e != nil {
74 try(e)
75 r.Err = e
76 return r
77 }
78
79 req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0.0")
80
81 // куки и UA-шник
82 if UserAgent != "" {
83 req.Header.Set("User-Agent", UserAgent)
84 }
85 if len(other) != 0 {
86 req.Header.Set("Cookie", other[0])
87 }
88
89 resp, e := cli.Do(req)
90 if e != nil {
91 // resp is nil on error: returning here avoids dereferencing it, which
92 // used to panic and (from UpdateCSRF's goroutine) kill the process.
93 try(e)
94 r.Err = e
95 return r
96 }
97 defer resp.Body.Close()
98
99 body, e := io.ReadAll(resp.Body)
100 if e != nil {
101 try(e)
102 r.Err = e
103 }
104
105 // заполняем структуру
106 r.Body = string(body)
107 r.Cookies = resp.Cookies()
108 r.Headers = resp.Header
109 r.Status = resp.StatusCode
110
111 return r
112}
113
114// looksLikeJSON reports whether a response is actually JSON, so an HTML page from
115// a CDN/edge never reaches json.Unmarshal.
116func looksLikeJSON(r reqrt) bool {
117 if ct := r.Headers.Get("Content-Type"); ct != "" && !strings.Contains(ct, "json") {
118 return false
119 }
120 b := strings.TrimSpace(r.Body)
121 return len(b) > 0 && (b[0] == '{' || b[0] == '[')
122}
123
124// describe renders a failed response as a readable message, instead of the opaque
125// `invalid character '<'` you get from json.Unmarshal on an HTML error page.
126func describe(r reqrt) string {
127 body := strings.TrimSpace(r.Body)
128 if looksLikeJSON(r) {
129 return body // DA's own JSON error; callers unmarshal it into Error
130 }
131
132 msg := "devianter: HTTP " + strconv.Itoa(r.Status) + " non-JSON response from DeviantArt"
133 if strings.Contains(body, "Generated by cloudfront") || strings.Contains(body, "Request blocked") {
134 msg += ": blocked by CloudFront/WAF — this egress IP is likely banned"
135 }
136 if len(body) > 200 {
137 body = body[:200] + "..."
138 }
139 return msg + " — " + body
140}
141
142/* PUPPY aka DeviantArt API */
143// получение или обновление токена
144var cookie string
145var token string
146
147const (
148 csrfPrefix = "window.__CSRF_TOKEN__ = '"
149 xhrMarker = "window.__XHR_LOCAL__"
150)
151
152func UpdateCSRF() error {
153 if cookie == "" {
154 req := request("https://www.deviantart.com/_puppy")
155
156 for _, content := range req.Cookies {
157 cookie = content.Raw
158 }
159 }
160
161 req := request("https://www.deviantart.com", cookie)
162 if req.Err != nil {
163 return req.Err
164 }
165 if req.Status != 200 {
166 return errors.New(describe(req))
167 }
168
169 // Bounds-check the markers. On a block/challenge page they are absent, and the
170 // old arithmetic sliced Body[24:-4] — a panic that killed the whole process.
171 start, end := strings.Index(req.Body, csrfPrefix), strings.Index(req.Body, xhrMarker)
172 if start < 0 || end < 0 {
173 return errors.New("devianter: CSRF token not found in homepage (blocked, challenged, or markup changed)")
174 }
175 start += len(csrfPrefix)
176 end -= 3
177 if end <= start || end > len(req.Body) {
178 return errors.New("devianter: CSRF token markers out of order (markup changed)")
179 }
180 token = req.Body[start:end]
181
182 return nil
183}
184
185func puppy(data string) (string, error) {
186 var url strings.Builder
187 url.WriteString("https://www.deviantart.com/_puppy/")
188 url.WriteString(data)
189 url.WriteString("&csrf_token=")
190 url.WriteString(token)
191 url.WriteString("&da_minor_version=20230710")
192
193 body := request(url.String(), cookie)
194 if body.Err != nil {
195 return "", body.Err
196 }
197
198 // если код ответа не 200, возвращается ошибка
199 if body.Status != 200 {
200 return "", errors.New(describe(body))
201 }
202
203 // A 200 that isn't JSON means an edge/CDN page slipped through.
204 if !looksLikeJSON(body) {
205 return "", errors.New(describe(body))
206 }
207
208 return body.Body, nil
209}