aboutsummaryrefslogtreecommitdiff
path: root/util.go
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-07-14 18:32:39 -0500
committerChristian Cleberg <[email protected]>2026-07-14 18:32:39 -0500
commit1044b66ee7180d28ade26c8174f3c5e2e1430b16 (patch)
tree4df1259342f4ff5bc3b8d9be2f4e7a5178e94eea /util.go
parentb3c99749f133abf6c04506135c2e4e7567da5254 (diff)
downloaddevianter-1044b66ee7180d28ade26c8174f3c5e2e1430b16.tar.gz
devianter-1044b66ee7180d28ade26c8174f3c5e2e1430b16.tar.bz2
devianter-1044b66ee7180d28ade26c8174f3c5e2e1430b16.zip
fix: resolve nil-deref crash, CSRF panic, add timeouts + clear CDN-block errors; repoint module to zerolabsco
References: https://github.com/zerolabsco/skunky-art/issues/2
Diffstat (limited to 'util.go')
-rw-r--r--util.go101
1 files changed, 93 insertions, 8 deletions
diff --git a/util.go b/util.go
index 7df68cb..dddd7db 100644
--- a/util.go
+++ b/util.go
@@ -5,7 +5,9 @@ import (
"errors"
"io"
"net/http"
+ "strconv"
"strings"
+ "time"
)
// функция для высера ошибки в stderr
@@ -31,7 +33,13 @@ type Error struct {
func APIError(inputError error) (err Error) {
if inputError != nil {
err.RAW = []byte(inputError.Error())
- try(json.Unmarshal(err.RAW, &err))
+ // DA's API errors are JSON. Anything else (CDN block pages, transport
+ // failures) is surfaced as-is rather than spamming a JSON parse error —
+ // this is what used to print `invalid character '<'` on every page.
+ if json.Unmarshal(err.RAW, &err) != nil {
+ err.Reason = "request_failed"
+ err.Error = inputError.Error()
+ }
}
return
}
@@ -43,18 +51,30 @@ type reqrt struct {
Status int
Cookies []*http.Cookie
Headers http.Header
+ // Err is set when the request never completed (transport error). Status is 0.
+ Err error
}
// функция для совершения запроса
var UserAgent string
+// Timeout bounds a single request end-to-end (dial, response, body read).
+// Without it, a hung connection blocks its caller forever.
+var Timeout = 30 * time.Second
+
func request(uri string, other ...string) reqrt {
var r reqrt
// создаём новый запрос
- cli := &http.Client{}
+ // Transport is deliberately left nil so http.DefaultTransport applies: that
+ // keeps HTTPS_PROXY support and lets callers wrap it (e.g. to rate-limit).
+ cli := &http.Client{Timeout: Timeout}
req, e := http.NewRequest("GET", uri, nil)
- try(e)
+ if e != nil {
+ try(e)
+ r.Err = e
+ return r
+ }
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64; rv:123.0) Gecko/20100101 Firefox/123.0.0")
@@ -67,11 +87,20 @@ func request(uri string, other ...string) reqrt {
}
resp, e := cli.Do(req)
- try(e)
+ if e != nil {
+ // resp is nil on error: returning here avoids dereferencing it, which
+ // used to panic and (from UpdateCSRF's goroutine) kill the process.
+ try(e)
+ r.Err = e
+ return r
+ }
defer resp.Body.Close()
body, e := io.ReadAll(resp.Body)
- try(e)
+ if e != nil {
+ try(e)
+ r.Err = e
+ }
// заполняем структуру
r.Body = string(body)
@@ -82,11 +111,44 @@ func request(uri string, other ...string) reqrt {
return r
}
+// looksLikeJSON reports whether a response is actually JSON, so an HTML page from
+// a CDN/edge never reaches json.Unmarshal.
+func looksLikeJSON(r reqrt) bool {
+ if ct := r.Headers.Get("Content-Type"); ct != "" && !strings.Contains(ct, "json") {
+ return false
+ }
+ b := strings.TrimSpace(r.Body)
+ return len(b) > 0 && (b[0] == '{' || b[0] == '[')
+}
+
+// describe renders a failed response as a readable message, instead of the opaque
+// `invalid character '<'` you get from json.Unmarshal on an HTML error page.
+func describe(r reqrt) string {
+ body := strings.TrimSpace(r.Body)
+ if looksLikeJSON(r) {
+ return body // DA's own JSON error; callers unmarshal it into Error
+ }
+
+ msg := "devianter: HTTP " + strconv.Itoa(r.Status) + " non-JSON response from DeviantArt"
+ if strings.Contains(body, "Generated by cloudfront") || strings.Contains(body, "Request blocked") {
+ msg += ": blocked by CloudFront/WAF — this egress IP is likely banned"
+ }
+ if len(body) > 200 {
+ body = body[:200] + "..."
+ }
+ return msg + " — " + body
+}
+
/* PUPPY aka DeviantArt API */
// получение или обновление токена
var cookie string
var token string
+const (
+ csrfPrefix = "window.__CSRF_TOKEN__ = '"
+ xhrMarker = "window.__XHR_LOCAL__"
+)
+
func UpdateCSRF() error {
if cookie == "" {
req := request("https://www.deviantart.com/_puppy")
@@ -97,10 +159,25 @@ func UpdateCSRF() error {
}
req := request("https://www.deviantart.com", cookie)
+ if req.Err != nil {
+ return req.Err
+ }
if req.Status != 200 {
- return errors.New(req.Body)
+ return errors.New(describe(req))
}
- token = req.Body[strings.Index(req.Body, "window.__CSRF_TOKEN__ = '")+25 : strings.Index(req.Body, "window.__XHR_LOCAL__")-3]
+
+ // Bounds-check the markers. On a block/challenge page they are absent, and the
+ // old arithmetic sliced Body[24:-4] — a panic that killed the whole process.
+ start, end := strings.Index(req.Body, csrfPrefix), strings.Index(req.Body, xhrMarker)
+ if start < 0 || end < 0 {
+ return errors.New("devianter: CSRF token not found in homepage (blocked, challenged, or markup changed)")
+ }
+ start += len(csrfPrefix)
+ end -= 3
+ if end <= start || end > len(req.Body) {
+ return errors.New("devianter: CSRF token markers out of order (markup changed)")
+ }
+ token = req.Body[start:end]
return nil
}
@@ -114,10 +191,18 @@ func puppy(data string) (string, error) {
url.WriteString("&da_minor_version=20230710")
body := request(url.String(), cookie)
+ if body.Err != nil {
+ return "", body.Err
+ }
// если код ответа не 200, возвращается ошибка
if body.Status != 200 {
- return "", errors.New(body.Body)
+ return "", errors.New(describe(body))
+ }
+
+ // A 200 that isn't JSON means an edge/CDN page slipped through.
+ if !looksLikeJSON(body) {
+ return "", errors.New(describe(body))
}
return body.Body, nil