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}