diff --git a/nitter.example.conf b/nitter.example.conf index 0d4deb7cf..8d078f153 100644 --- a/nitter.example.conf +++ b/nitter.example.conf @@ -33,6 +33,9 @@ tokenCount = 10 # always at least `tokenCount` usable tokens. only increase this if you receive # major bursts all the time and don't have a rate limiting setup via e.g. nginx +#cookieHeader = "ct0=XXXXXXXXXXXXXXXXX; auth_token=XXXXXXXXXXXXXX" # authentication cookie of a logged in account, required for the likes tab and NSFW content +#xCsrfToken = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # required for the likes tab and NSFW content + # Change default preferences here, see src/prefs_impl.nim for a complete list [Preferences] theme = "Nitter" diff --git a/src/api.nim b/src/api.nim index b23aa8730..c63a5b5fe 100644 --- a/src/api.nim +++ b/src/api.nim @@ -3,6 +3,7 @@ import asyncdispatch, httpclient, uri, strutils, sequtils, sugar import packedjson import types, query, formatters, consts, apiutils, parser import experimental/parser as newParser +import config proc getGraphUser*(username: string): Future[User] {.async.} = if username.len == 0: return @@ -69,6 +70,13 @@ proc getGraphListMembers*(list: List; after=""): Future[Result[User]] {.async.} let url = graphListMembers ? {"variables": $variables, "features": gqlFeatures} result = parseGraphListMembers(await fetchRaw(url, Api.listMembers), after) +proc getFavorites*(id: string; cfg: Config; after=""): Future[Timeline] {.async.} = + if id.len == 0: return + let + ps = genParams({"userId": id}, after) + url = consts.favorites / (id & ".json") ? ps + result = parseTimeline(await fetch(url, Api.favorites), after) + proc getGraphTweetResult*(id: string): Future[Tweet] {.async.} = if id.len == 0: return let diff --git a/src/apiutils.nim b/src/apiutils.nim index dbc6cca68..bc4b49874 100644 --- a/src/apiutils.nim +++ b/src/apiutils.nim @@ -3,6 +3,7 @@ import httpclient, asyncdispatch, options, strutils, uri import jsony, packedjson, zippy import types, tokens, consts, parserutils, http_pool import experimental/types/common +import config const rlRemaining = "x-rate-limit-remaining" @@ -50,7 +51,7 @@ template updateToken() = reset = parseInt(resp.headers[rlReset]) token.setRateLimit(api, remaining, reset) -template fetchImpl(result, fetchBody) {.dirty.} = +template fetchImpl(result, additional_headers, fetchBody) {.dirty.} = once: pool = HttpPool() @@ -60,7 +61,10 @@ template fetchImpl(result, fetchBody) {.dirty.} = try: var resp: AsyncResponse - pool.use(genHeaders(token)): + var headers = genHeaders(token) + for key, value in additional_headers.pairs(): + headers.add(key, value) + pool.use(headers): template getContent = resp = await c.get($url) result = await resp.body @@ -94,9 +98,15 @@ template fetchImpl(result, fetchBody) {.dirty.} = release(token, invalid=true) raise rateLimitError() -proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = +proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} = + + if len(cfg.cookieHeader) != 0: + additional_headers.add("Cookie", cfg.cookieHeader) + if len(cfg.xCsrfToken) != 0: + additional_headers.add("x-csrf-token", cfg.xCsrfToken) + var body: string - fetchImpl body: + fetchImpl(body, additional_headers): if body.startsWith('{') or body.startsWith('['): result = parseJson(body) else: @@ -111,8 +121,8 @@ proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = release(token, invalid=true) raise rateLimitError() -proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = - fetchImpl result: +proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} = + fetchImpl(result, additional_headers): if not (result.startsWith('{') or result.startsWith('[')): echo resp.status, ": ", result, " --- url: ", url result.setLen(0) diff --git a/src/config.nim b/src/config.nim index 1b05ffec1..fe4aba51c 100644 --- a/src/config.nim +++ b/src/config.nim @@ -1,6 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only import parsecfg except Config import types, strutils +from os import getEnv proc get*[T](config: parseCfg.Config; section, key: string; default: T): T = let val = config.getSectionValue(section, key) @@ -40,7 +41,13 @@ proc getConfig*(path: string): (Config, parseCfg.Config) = enableRss: cfg.get("Config", "enableRSS", true), enableDebug: cfg.get("Config", "enableDebug", false), proxy: cfg.get("Config", "proxy", ""), - proxyAuth: cfg.get("Config", "proxyAuth", "") + proxyAuth: cfg.get("Config", "proxyAuth", ""), + cookieHeader: cfg.get("Config", "cookieHeader", ""), + xCsrfToken: cfg.get("Config", "xCsrfToken", "") ) return (conf, cfg) + + +let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf") +let (cfg*, fullCfg*) = getConfig(configPath) diff --git a/src/consts.nim b/src/consts.nim index f22581f01..406387666 100644 --- a/src/consts.nim +++ b/src/consts.nim @@ -8,6 +8,9 @@ const activate* = $(api / "1.1/guest/activate.json") photoRail* = api / "1.1/statuses/media_timeline.json" + + timelineApi = api / "2/timeline" + favorites* = timelineApi / "favorites" userSearch* = api / "1.1/users/search.json" graphql = api / "graphql" diff --git a/src/nitter.nim b/src/nitter.nim index 627af75b4..e84b4283a 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -2,7 +2,6 @@ import asyncdispatch, strformat, logging from net import Port from htmlgen import a -from os import getEnv import jester @@ -15,9 +14,6 @@ import routes/[ const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" -let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf") -let (cfg, fullCfg) = getConfig(configPath) - if not cfg.enableDebug: # Silence Jester's query warning addHandler(newConsoleLogger()) diff --git a/src/query.nim b/src/query.nim index d128f6f64..49c585659 100644 --- a/src/query.nim +++ b/src/query.nim @@ -40,6 +40,13 @@ proc getMediaQuery*(name: string): Query = sep: "OR" ) + +proc getFavoritesQuery*(name: string): Query = + Query( + kind: favorites, + fromUser: @[name] + ) + proc getReplyQuery*(name: string): Query = Query( kind: replies, diff --git a/src/routes/rss.nim b/src/routes/rss.nim index 1323ed3e2..c3d27ccce 100644 --- a/src/routes/rss.nim +++ b/src/routes/rss.nim @@ -23,7 +23,7 @@ proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async. names = getNames(name) if names.len == 1: - profile = await fetchProfile(after, query, skipRail=true, skipPinned=true) + profile = await fetchProfile(after, query, cfg, skipRail=true, skipPinned=true) else: var q = query q.fromUser = names @@ -104,7 +104,7 @@ proc createRssRouter*(cfg: Config) = get "/@name/@tab/rss": cond cfg.enableRss cond '.' notin @"name" - cond @"tab" in ["with_replies", "media", "search"] + cond @"tab" in ["with_replies", "media", "favorites", "search"] let name = @"name" tab = @"tab" @@ -112,6 +112,7 @@ proc createRssRouter*(cfg: Config) = case tab of "with_replies": getReplyQuery(name) of "media": getMediaQuery(name) + of "favorites": getFavoritesQuery(name) of "search": initQuery(params(request), name=name) else: Query(fromUser: @[name]) diff --git a/src/routes/search.nim b/src/routes/search.nim index 02c14e30d..6c50412d1 100644 --- a/src/routes/search.nim +++ b/src/routes/search.nim @@ -37,7 +37,7 @@ proc createSearchRouter*(cfg: Config) = let tweets = await getGraphSearch(query, getCursor()) rss = "/search/rss?" & genQueryUrl(query) - resp renderMain(renderTweetSearch(tweets, prefs, getPath()), + resp renderMain(renderTweetSearch(tweets, cfg, prefs, getPath()), request, cfg, prefs, title, rss=rss) else: resp Http404, showError("Invalid search", cfg) diff --git a/src/routes/timeline.nim b/src/routes/timeline.nim index 331b8ae0b..b4499eafc 100644 --- a/src/routes/timeline.nim +++ b/src/routes/timeline.nim @@ -16,6 +16,7 @@ proc getQuery*(request: Request; tab, name: string): Query = case tab of "with_replies": getReplyQuery(name) of "media": getMediaQuery(name) + of "favorites": getFavoritesQuery(name) of "search": initQuery(params(request), name=name) else: Query(fromUser: @[name]) @@ -27,7 +28,7 @@ template skipIf[T](cond: bool; default; body: Future[T]): Future[T] = else: body -proc fetchProfile*(after: string; query: Query; skipRail=false; +proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false; skipPinned=false): Future[Profile] {.async.} = let name = query.fromUser[0] @@ -50,6 +51,7 @@ proc fetchProfile*(after: string; query: Query; skipRail=false; of posts: getGraphUserTweets(userId, TimelineKind.tweets, after) of replies: getGraphUserTweets(userId, TimelineKind.replies, after) of media: getGraphUserTweets(userId, TimelineKind.media, after) + of favorites: getFavorites(userId, cfg, after) else: getGraphSearch(query, after) rail = @@ -84,10 +86,10 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; if query.fromUser.len != 1: let timeline = await getGraphSearch(query, after) - html = renderTweetSearch(timeline, prefs, getPath()) + html = renderTweetSearch(timeline, cfg, prefs, getPath()) return renderMain(html, request, cfg, prefs, "Multi", rss=rss) - var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins) + var profile = await fetchProfile(after, query, cfg, skipPinned=prefs.hidePins) template u: untyped = profile.user if u.suspended: @@ -95,7 +97,7 @@ proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; if profile.user.id.len == 0: return - let pHtml = renderProfile(profile, prefs, getPath()) + let pHtml = renderProfile(profile, cfg, prefs, getPath()) result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u), rss=rss, images = @[u.getUserPic("_400x400")], banner=u.banner) @@ -125,7 +127,7 @@ proc createTimelineRouter*(cfg: Config) = get "/@name/?@tab?/?": cond '.' notin @"name" cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] - cond @"tab" in ["with_replies", "media", "search", ""] + cond @"tab" in ["with_replies", "media", "search", "favorites", ""] let prefs = cookiePrefs() after = getCursor() @@ -141,9 +143,9 @@ proc createTimelineRouter*(cfg: Config) = var timeline = await getGraphSearch(query, after) if timeline.content.len == 0: resp Http404 timeline.beginning = true - resp $renderTweetSearch(timeline, prefs, getPath()) + resp $renderTweetSearch(timeline, cfg, prefs, getPath()) else: - var profile = await fetchProfile(after, query, skipRail=true) + var profile = await fetchProfile(after, query, cfg, skipRail=true) if profile.tweets.content.len == 0: resp Http404 profile.tweets.beginning = true resp $renderTimelineTweets(profile.tweets, prefs, getPath()) diff --git a/src/tokens.nim b/src/tokens.nim index 6ef81f5d4..d128cc14e 100644 --- a/src/tokens.nim +++ b/src/tokens.nim @@ -45,7 +45,7 @@ proc getPoolJson*(): JsonNode = of Api.listMembers, Api.listBySlug, Api.list, Api.listTweets, Api.userTweets, Api.userTweetsAndReplies, Api.userMedia, Api.userRestId, Api.userScreenName, - Api.tweetDetail, Api.tweetResult, Api.search: 500 + Api.tweetDetail, Api.tweetResult, Api.search, Api.favorites: 500 of Api.userSearch: 900 reqs = maxReqs - token.apis[api].remaining diff --git a/src/types.nim b/src/types.nim index 4dca5f016..d080ee2b6 100644 --- a/src/types.nim +++ b/src/types.nim @@ -26,6 +26,7 @@ type listTweets userRestId userScreenName + favorites userTweets userTweetsAndReplies userMedia @@ -106,7 +107,7 @@ type variants*: seq[VideoVariant] QueryKind* = enum - posts, replies, media, users, tweets, userList + posts, replies, media, users, tweets, userList, favorites Query* = object kind*: QueryKind @@ -269,6 +270,9 @@ type redisMaxConns*: int redisPassword*: string + cookieHeader*: string + xCsrfToken*: string + Rss* = object feed*, cursor*: string diff --git a/src/views/profile.nim b/src/views/profile.nim index 2b2e4102b..75cc16970 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -99,7 +99,7 @@ proc renderProtected(username: string): VNode = h2: text "This account's tweets are protected." p: text &"Only confirmed followers have access to @{username}'s tweets." -proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = +proc renderProfile*(profile: var Profile; cfg: Config; prefs: Prefs; path: string): VNode = profile.tweets.query.fromUser = @[profile.user.username] buildHtml(tdiv(class="profile-tabs")): @@ -116,4 +116,4 @@ proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = if profile.user.protected: renderProtected(profile.user.username) else: - renderTweetSearch(profile.tweets, prefs, path, profile.pinned) + renderTweetSearch(profile.tweets, cfg, prefs, path, profile.pinned) diff --git a/src/views/search.nim b/src/views/search.nim index 72c59f50e..df210a52e 100644 --- a/src/views/search.nim +++ b/src/views/search.nim @@ -29,7 +29,7 @@ proc renderSearch*(): VNode = placeholder="Enter username...", dir="auto") button(`type`="submit"): icon "search" -proc renderProfileTabs*(query: Query; username: string): VNode = +proc renderProfileTabs*(query: Query; username: string; cfg: Config): VNode = let link = "/" & username buildHtml(ul(class="tab")): li(class=query.getTabClass(posts)): @@ -38,6 +38,9 @@ proc renderProfileTabs*(query: Query; username: string): VNode = a(href=(link & "/with_replies")): text "Tweets & Replies" li(class=query.getTabClass(media)): a(href=(link & "/media")): text "Media" + if len(cfg.xCsrfToken) != 0 and len(cfg.cookieHeader) != 0: + li(class=query.getTabClass(favorites)): + a(href=(link & "/favorites")): text "Likes" li(class=query.getTabClass(tweets)): a(href=(link & "/search")): text "Search" @@ -88,7 +91,7 @@ proc renderSearchPanel*(query: Query): VNode = span(class="search-title"): text "Near" genInput("near", "", query.near, "Location...", autofocus=false) -proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string; +proc renderTweetSearch*(results: Result[Tweet]; cfg: Config; prefs: Prefs; path: string; pinned=none(Tweet)): VNode = let query = results.query buildHtml(tdiv(class="timeline-container")): @@ -97,7 +100,7 @@ proc renderTweetSearch*(results: Result[Tweet]; prefs: Prefs; path: string; text query.fromUser.join(" | ") if query.fromUser.len > 0: - renderProfileTabs(query, query.fromUser.join(",")) + renderProfileTabs(query, query.fromUser.join(","), cfg) if query.fromUser.len == 0 or query.kind == tweets: tdiv(class="timeline-header"):