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