diff --git a/src/tweetlog.nim b/src/tweetlog.nim index d76e1b3..23bf4d1 100644 --- a/src/tweetlog.nim +++ b/src/tweetlog.nim @@ -1,5 +1,6 @@ import tweetlogpkg/twitter, tweetlogpkg/server import threadpool +import tweetlogpkg/auth import httpClient, base64, uri, json, os, strformat, sequtils, strutils, options import timezones, times diff --git a/src/tweetlogpkg/auth.nim b/src/tweetlogpkg/auth.nim new file mode 100644 index 0000000..967fdf0 --- /dev/null +++ b/src/tweetlogpkg/auth.nim @@ -0,0 +1,131 @@ +import httpClient, base64, uri, json, os, strformat, sequtils, strutils, options, sugar, times, types +import tables, algorithm, base64, math, options +import nimcrypto + +proc tweetClient*(token : string) : HttpClient = + var client = newHttpClient() + client.headers = newHttpHeaders( + { + "Authorization" : token + } + ) + client + +# client credentials flow +proc buildAuthHeader() : string = + let consumerKey = "TWITTER_CONSUMER_KEY".getEnv + let secret = "TWITTER_CONSUMER_SECRET".getEnv + "Basic " & (consumerKey.encodeUrl & ":" & secret.encodeUrl).encode + +proc getBearerToken*() : string = + var client = newHttpClient() + client.headers = newHttpHeaders( + { + "Content-Type" : "application/x-www-form-urlencoded;charset=UTF-8", + "Authorization" : buildAuthHeader() + } + ) + + let body = "grant_type=client_credentials" + + let response = client.request("https://api.twitter.com/oauth2/token", + httpMethod = HttpPost, + body = body).body.parseJson + + let responseType = response["token_type"].getStr + + assert(responseType == "bearer") + + "Bearer " & response["access_token"].getStr + +# 3-legged OAuth stuff +type Params = Table[string, string] +type OAuthToken = tuple[token: string, token_secret: string] + +proc generateNonce() : string = + let alphabet = map(toSeq(65..90).concat( + toSeq(97..122)).concat( + toSeq(49..57)), (c) => char(c)) + + var randBytes : array[50, uint8] + discard randomBytes(randBytes) + return map(randBytes, (c) => alphabet[(c.int %% alphabet.len)]).join + +proc constructEncodedString(params : Params, sep : string, include_quotes : bool) : string = + var encodedPairs : seq[string] = @[] + var keyPairs : seq[tuple[key: string, value: string]] = toSeq(params.pairs) + + keyPairs.sort((a, b) => cmp(a.key.encodeUrl, b.key.encodeUrl)) + + for pair in keyPairs: + if include_quotes: + encodedPairs &= pair[0].encodeUrl & "=" & "\"" & pair[1].encodeUrl & "\"" + else: + encodedPairs &= pair[0].encodeUrl & "=" & pair[1].encodeUrl + + encodedPairs.join(sep) + +proc constructParameterString(params : Params) : string = + params.constructEncodedString("&", include_quotes=false) + +proc constructHeaderString(params : Params) : string = + "OAuth " & params.constructEncodedString(", ", include_quotes=true) + +proc sign(reqMethod : string, + paramString : string, + baseUrl : string, + accessToken : string = "") : string = + let sigBaseString : string = reqMethod.toUpperAscii & "&" & baseUrl.encodeUrl & "&" & paramString.encodeUrl + + let signingKey : string = getEnv("TWITTER_CONSUMER_SECRET").encodeUrl & "&" & accessToken + sha256.hmac(signingKey, sigBaseString).data.encode + +proc requestToken*(requestUrl : string, requestMethod : string, requestBody : string) : Option[OAuthToken] = + # Obtain a request token for OAuth + # as well as a request token secret + # these are used to authenticate a specific user + let callback = getEnv("TWITTER_OAUTH_CALLBACK") + let consumerKey = getEnv("TWITTER_CONSUMER_KEY") + + var headers = newHttpHeaders([]) + + let oauth_nonce = generateNonce() + + # The twitter documentation uses SHA1, but this works and is future-proof + let oauth_signature_method = "HMAC-SHA256" + let oauth_timestamp : string = $trunc(epochTime()).uint64 + + var params : Params = { + "oauth_nonce" : oauth_nonce, + "oauth_signature_method" : oauth_signature_method, + "oauth_callback" : callback, + "oauth_timestamp" : oauth_timestamp, + "oauth_consumer_key" : consumerKey, + "oauth_version" : "1.0" + }.toTable + + let paramString = params.constructParameterString + + let signature = sign(requestMethod, paramString, requestUrl) + + params["oauth_signature"] = signature + + let client = tweetClient(params.constructHeaderString) + + let resp = client.request(requestUrl, httpMethod = HttpPost, headers = headers, body = requestBody) + + if resp.status != "200 OK": + echo resp.body + return none(OAuthToken) + + let keyPairs : Table[string, string] = toTable( + map(resp.body.split("&"), + proc(pair : string) : tuple[a: string, b: string] = + let split = pair.split("=") + (split[0], split[1]))) + + if not (keyPairs.hasKey("oauth_token") and keyPairs.hasKey("oauth_token_secret")): + return none(OAuthToken) + + some((token: keyPairs["oauth_token"], token_secret: keyPairs["oauth_token_secret"])) + diff --git a/src/tweetlogpkg/twitter.nim b/src/tweetlogpkg/twitter.nim index 666e81e..86973ba 100644 --- a/src/tweetlogpkg/twitter.nim +++ b/src/tweetlogpkg/twitter.nim @@ -1,6 +1,6 @@ -import httpClient, base64, uri, json, os, strformat, sequtils, strutils, options, sugar, timezones, times, types -import tables, algorithm, base64, math, options -import nimcrypto +import httpClient, uri, json, os, strformat, sequtils, strutils, options, sugar, types +import tables, options, times +import auth from nimcrypto.sysrand import randomBytes from xmltree import escape @@ -8,47 +8,10 @@ from xmltree import escape proc parseTwitterTS(ts : string) : DateTime = ts.parse("ddd MMM dd hh:mm:ss YYYY") -# echo "Sun Feb 16 18:19:17 +0000 2020".parseTwitterTS.repr - -proc buildAuthHeader() : string = - let consumerKey = "TWITTER_CONSUMER_KEY".getEnv - let secret = "TWITTER_CONSUMER_SECRET".getEnv - "Basic " & (consumerKey.encodeUrl & ":" & secret.encodeUrl).encode - -proc getToken*() : string = - var client = newHttpClient() - client.headers = newHttpHeaders( - { - "Content-Type" : "application/x-www-form-urlencoded;charset=UTF-8", - "Authorization" : buildAuthHeader() - } - ) - - let body = "grant_type=client_credentials" - - let response = client.request("https://api.twitter.com/oauth2/token", - httpMethod = HttpPost, - body = body).body.parseJson - - let responseType = response["token_type"].getStr - - assert(responseType == "bearer") - - "Bearer " & response["access_token"].getStr - -proc tweetClient() : HttpClient = - var client = newHttpClient() - client.headers = newHttpHeaders( - { - "Authorization" : getToken() - } - ) - client - proc listTweets*(user : string) : JsonNode = # Lists tweets from a given user # XXX use Tweet type - let client = tweetClient() + let client = tweetClient(getBearerToken()) let userIdReq = fmt"/2/users/by?usernames={user}" var url = fmt"https://api.twitter.com{userIdReq}" @@ -60,7 +23,7 @@ proc listTweets*(user : string) : JsonNode = proc getTweetConvo*(tweetID : string) : JsonNode = # Gets the conversation info for a given tweet - let client = tweetClient() + let client = tweetClient(getBearerToken()) let userIdReq = fmt"/2/tweets?ids={tweetID}&tweet.fields=conversation_id,author_id" var url = fmt"https://api.twitter.com{userIdReq}" @@ -71,7 +34,7 @@ proc getTweetConvo*(tweetID : string) : JsonNode = proc getTweet*(tweetID : string) : string = # Grabs a single tweet # XXX use Tweet type - let client = tweetClient() + let client = tweetClient(getBearerToken()) let reqTarget = fmt"/1.1/statuses/show.json?id={tweetID}&tweet_mode=extended" let url = fmt"https://api.twitter.com{reqTarget}" @@ -79,14 +42,14 @@ proc getTweet*(tweetID : string) : string = proc getHome*(count: int) : string = # Gets your home timeline - let client = tweetClient() + let client = tweetClient(getBearerToken()) let reqTarget = fmt"/1.1/statuses/user_timeline.json?count={count}&trim_user=1&exclude_replies=1" let url = fmt"https://api.twitter.com{reqTarget}" client.request(url, httpMethod = HttpGet).body iterator getThread*(tweetStart : string) : Tweet = - let client = tweetClient() + let client = tweetClient(getBearerToken()) var reqTarget = fmt"/2/tweets/search/recent?query=conversation_id:{tweetStart}&tweet.fields=in_reply_to_user_id,author_id,created_at,conversation_id" var url = fmt"https://api.twitter.com{reqTarget}" @@ -140,94 +103,3 @@ proc renderThread*(tweetID : string) : Option[seq[string]] = if thread.len == 0: return none(seq[string]) some(thread) - -# 3-legged OAuth stuff -type Params = Table[string, string] -type OAuthToken = tuple[token: string, token_secret: string] - -proc generateNonce*() : string = - let alphabet = map(toSeq(65..90).concat( - toSeq(97..122)).concat( - toSeq(49..57)), (c) => char(c)) - - var randBytes : array[50, uint8] - discard randomBytes(randBytes) - return map(randBytes, (c) => alphabet[(c.int %% alphabet.len)]).join - -proc constructEncodedString(params : Params, sep : string, include_quotes : bool) : string = - var encodedPairs : seq[string] = @[] - var keyPairs : seq[tuple[key: string, value: string]] = toSeq(params.pairs) - - keyPairs.sort((a, b) => cmp(a.key.encodeUrl, b.key.encodeUrl)) - - for pair in keyPairs: - if include_quotes: - encodedPairs &= pair[0].encodeUrl & "=" & "\"" & pair[1].encodeUrl & "\"" - else: - encodedPairs &= pair[0].encodeUrl & "=" & pair[1].encodeUrl - - encodedPairs.join(sep) - -proc constructParameterString(params : Params) : string = - params.constructEncodedString("&", include_quotes=false) - -proc constructHeaderString(params : Params) : string = - "OAuth " & params.constructEncodedString(", ", include_quotes=true) - -proc sign(reqMethod : string, - paramString : string, - baseUrl : string, - accessToken : string = "") : string = - let sigBaseString : string = reqMethod.toUpperAscii & "&" & baseUrl.encodeUrl & "&" & paramString.encodeUrl - - let signingKey : string = getEnv("TWITTER_CONSUMER_SECRET").encodeUrl & "&" & accessToken - sha256.hmac(signingKey, sigBaseString).data.encode - -proc requestToken*(requestUrl : string, requestMethod : string, requestBody : string) : Option[OAuthToken] = - # Obtain a request token for OAuth - # as well as a request token secret - # these are used to authenticate a specific user - let client = tweetClient() - let callback = getEnv("TWITTER_OAUTH_CALLBACK") - let consumerKey = getEnv("TWITTER_CONSUMER_KEY") - - var headers = newHttpHeaders([]) - - let oauth_nonce = generateNonce() - - # The twitter documentation uses SHA1, but this works and is future-proof - let oauth_signature_method = "HMAC-SHA256" - let oauth_timestamp : string = $trunc(epochTime()).uint64 - - var params : Params = { - "oauth_nonce" : oauth_nonce, - "oauth_signature_method" : oauth_signature_method, - "oauth_callback" : callback, - "oauth_timestamp" : oauth_timestamp, - "oauth_consumer_key" : consumerKey, - "oauth_version" : "1.0" - }.toTable - - let paramString = params.constructParameterString - - let signature = sign(requestMethod, paramString, requestUrl) - - params["oauth_signature"] = signature - headers["Authorization"] = @[params.constructHeaderString] - - let resp = client.request(requestUrl, httpMethod = HttpPost, headers = headers, body = requestBody) - - if resp.status != "200 OK": - echo resp.body - return none(OAuthToken) - - let keyPairs : Table[string, string] = toTable( - map(resp.body.split("&"), - proc(pair : string) : tuple[a: string, b: string] = - let split = pair.split("=") - (split[0], split[1]))) - - if not (keyPairs.hasKey("oauth_token") and keyPairs.hasKey("oauth_token_secret")): - return none(OAuthToken) - - some((token: keyPairs["oauth_token"], token_secret: keyPairs["oauth_token_secret"])) diff --git a/tweetlog.nimble b/tweetlog.nimble index fb71867..37707ee 100644 --- a/tweetlog.nimble +++ b/tweetlog.nimble @@ -12,6 +12,8 @@ bin = @["tweetlog"] requires "nim >= 1.0" requires "https://github.com/dom96/jester" requires "https://github.com/pragmagic/karax" +requires "https://github.com/GULPF/timezones" +requires "https://github.com/cheatfate/nimcrypto" task bookmark, "Builds the minified bookmarklet": "echo -n 'javascript:' > ./bookmarklet.min.js".exec