apiutils.nim 7.0 KB

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