apiutils.nim 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. # SPDX-License-Identifier: AGPL-3.0-only
  2. import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
  3. import jsony, packedjson, zippy, oauth1
  4. import types, auth, consts, parserutils, http_pool, tid
  5. import experimental/types/common
  6. const
  7. rlRemaining = "x-rate-limit-remaining"
  8. rlReset = "x-rate-limit-reset"
  9. rlLimit = "x-rate-limit-limit"
  10. errorsToSkip = {null, doesntExist, tweetNotFound, timeout, unauthorized, badRequest}
  11. var
  12. pool: HttpPool
  13. disableTid: bool
  14. apiProxy: string
  15. maxRetries: int
  16. retryDelayMs: int
  17. proc setDisableTid*(disable: bool) =
  18. disableTid = disable
  19. proc setMaxRetries*(n: int) =
  20. maxRetries = n
  21. proc setRetryDelayMs*(ms: int) =
  22. retryDelayMs = ms
  23. proc setApiProxy*(url: string) =
  24. apiProxy = ""
  25. if url.len > 0:
  26. apiProxy = url.strip(chars={'/'}) & "/"
  27. if "http" notin apiProxy:
  28. apiProxy = "http://" & apiProxy
  29. proc toUrl(req: ApiReq; sessionKind: SessionKind): Uri =
  30. let url = case sessionKind
  31. of oauth: req.oauth
  32. of cookie: req.cookie
  33. let base = case sessionKind
  34. of oauth: "https://api.x.com"
  35. of cookie: "https://x.com/i/api"
  36. let prefix = if url.endpoint.startsWith("1.1/"): "" else: "graphql/"
  37. parseUri(base) / (prefix & url.endpoint) ? url.params
  38. proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
  39. let
  40. encodedUrl = url.replace(",", "%2C").replace("+", "%20")
  41. params = OAuth1Parameters(
  42. consumerKey: consumerKey,
  43. signatureMethod: "HMAC-SHA1",
  44. timestamp: $int(round(epochTime())),
  45. nonce: "0",
  46. isIncludeVersionToHeader: true,
  47. token: oauthToken
  48. )
  49. signature = getSignature(HttpGet, encodedUrl, "", params, consumerSecret, oauthTokenSecret)
  50. params.signature = percentEncode(signature)
  51. return getOauth1RequestHeader(params)["authorization"]
  52. proc getCookieHeader(authToken, ct0: string): string =
  53. "auth_token=" & authToken & "; ct0=" & ct0
  54. proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
  55. result = newHttpHeaders({
  56. "accept": "*/*",
  57. "accept-encoding": "gzip",
  58. "accept-language": "en-US,en;q=0.9",
  59. "connection": "keep-alive",
  60. "content-type": "application/json",
  61. "origin": "https://x.com",
  62. "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
  63. "x-twitter-active-user": "yes",
  64. "x-twitter-client-language": "en",
  65. "priority": "u=1, i"
  66. })
  67. case session.kind
  68. of SessionKind.oauth:
  69. result["authorization"] = getOauthHeader($url, session.oauthToken, session.oauthSecret)
  70. of SessionKind.cookie:
  71. result["x-twitter-auth-type"] = "OAuth2Session"
  72. result["x-csrf-token"] = session.ct0
  73. result["cookie"] = getCookieHeader(session.authToken, session.ct0)
  74. result["referer"] = "https://x.com/"
  75. result["sec-ch-ua"] = """"Google Chrome";v="142", "Chromium";v="142", "Not A(Brand";v="24""""
  76. result["sec-ch-ua-mobile"] = "?0"
  77. result["sec-ch-ua-platform"] = "Windows"
  78. result["sec-fetch-dest"] = "empty"
  79. result["sec-fetch-mode"] = "cors"
  80. result["sec-fetch-site"] = "same-origin"
  81. if disableTid or "/1.1/" in url.path:
  82. result["authorization"] = bearerToken2
  83. else:
  84. result["authorization"] = bearerToken
  85. result["x-client-transaction-id"] = await genTid(url.path)
  86. proc getAndValidateSession*(req: ApiReq): Future[Session] {.async.} =
  87. result = await getSession(req)
  88. case result.kind
  89. of SessionKind.oauth:
  90. if result.oauthToken.len == 0:
  91. echo "[sessions] Empty oauth token, session: ", result.pretty
  92. raise rateLimitError()
  93. of SessionKind.cookie:
  94. if result.authToken.len == 0 or result.ct0.len == 0:
  95. echo "[sessions] Empty cookie credentials, session: ", result.pretty
  96. raise rateLimitError()
  97. template fetchImpl(result, fetchBody) {.dirty.} =
  98. once:
  99. pool = HttpPool()
  100. try:
  101. var resp: AsyncResponse
  102. let headers = await genHeaders(session, url)
  103. pool.use(headers):
  104. template getContent =
  105. # TODO: this is a temporary simple implementation
  106. if apiProxy.len > 0 and "/1.1/" notin url.path:
  107. resp = await c.get(($url).replace("https://", apiProxy))
  108. else:
  109. resp = await c.get($url)
  110. result = await resp.body
  111. getContent()
  112. if resp.status == $Http503:
  113. badClient = true
  114. raise newException(BadClientError, "Bad client")
  115. if resp.status == $Http404 and result.len == 0:
  116. echo "[sessions] transient 404 (empty body), retrying: ", url.path, ", session: ", session.pretty
  117. raise rateLimitError()
  118. if resp.headers.hasKey(rlRemaining):
  119. let
  120. remaining = parseInt(resp.headers[rlRemaining])
  121. reset = parseInt(resp.headers[rlReset])
  122. limit = parseInt(resp.headers[rlLimit])
  123. session.setRateLimit(req, remaining, reset, limit)
  124. if result.len > 0:
  125. if resp.headers.getOrDefault("content-encoding") == "gzip":
  126. result = uncompress(result, dfGzip)
  127. if result.startsWith("{\"errors"):
  128. let errors = result.fromJson(Errors)
  129. if errors notin errorsToSkip:
  130. echo "Fetch error, API: ", url.path, ", errors: ", errors, ", session: ", session.pretty
  131. if errors in {expiredToken, badToken, locked}:
  132. invalidate(session)
  133. raise rateLimitError()
  134. elif errors in {rateLimited}:
  135. # rate limit hit, resets after 24 hours
  136. setLimited(session, req)
  137. raise rateLimitError()
  138. elif result.startsWith("429 Too Many Requests"):
  139. echo "[sessions] 429 error, API: ", url.path, ", session: ", session.pretty
  140. raise rateLimitError()
  141. fetchBody
  142. if resp.status == $Http400:
  143. echo "ERROR 400, ", url.path, ": ", result, ", session: ", session.pretty
  144. raise newException(InternalError, $url)
  145. except InternalError as e:
  146. raise e
  147. except BadClientError as e:
  148. raise e
  149. except OSError as e:
  150. raise e
  151. except Exception as e:
  152. let s = session.pretty
  153. echo "error: ", e.name, ", msg: ", e.msg, ", session: ", s, ", url: ", url
  154. raise rateLimitError()
  155. finally:
  156. release(session)
  157. template retry(bod) {.dirty.} =
  158. var session: Session
  159. for i in 0 ..< maxRetries:
  160. try:
  161. session = nil
  162. bod
  163. break
  164. except RateLimitError:
  165. let api = if session.isNil: req.cookie.endpoint
  166. else: req.endpoint(session)
  167. if session.isNil:
  168. echo "[sessions] Rate limited, retrying ", api,
  169. " request (", i, "/", maxRetries, ")..."
  170. else:
  171. echo "[sessions] Rate limited, retrying ", api,
  172. " request (", i, "/", maxRetries, ")..., session: ", session.pretty
  173. session = nil
  174. if retryDelayMs > 0:
  175. await sleepAsync(retryDelayMs)
  176. proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
  177. retry:
  178. var body: string
  179. session = await getAndValidateSession(req)
  180. let url = req.toUrl(session.kind)
  181. fetchImpl body:
  182. if body.startsWith('{') or body.startsWith('['):
  183. result = parseJson(body)
  184. else:
  185. echo resp.status, ": ", body, " --- url: ", url, ", session: ", session.pretty
  186. result = newJNull()
  187. let error = result.getError
  188. if error != null and error notin errorsToSkip:
  189. echo "Fetch error, API: ", url.path, ", error: ", error, ", session: ", session.pretty
  190. if error in {expiredToken, badToken, locked}:
  191. invalidate(session)
  192. raise rateLimitError()
  193. proc fetchRaw*(req: ApiReq): Future[string] {.async.} =
  194. retry:
  195. session = await getAndValidateSession(req)
  196. let url = req.toUrl(session.kind)
  197. fetchImpl result:
  198. if not (result.startsWith('{') or result.startsWith('[')):
  199. echo resp.status, ": ", result, " --- url: ", url, ", session: ", session.pretty
  200. result.setLen(0)