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}