diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 deleted file mode 100644 index 70024b2..0000000 --- a/Dockerfile.arm64 +++ /dev/null @@ -1,25 +0,0 @@ -FROM alpine:3.18 as nim -LABEL maintainer="setenforce@protonmail.com" - -RUN apk --no-cache add libsass-dev pcre gcc git libc-dev "nim=1.6.14-r0" "nimble=0.13.1-r2" - -WORKDIR /src/nitter - -COPY nitter.nimble . -RUN nimble install -y --depsOnly - -COPY . . -RUN nimble build -d:danger -d:lto -d:strip \ - && nimble scss \ - && nimble md - -FROM alpine:3.18 -WORKDIR /src/ -RUN apk --no-cache add pcre ca-certificates openssl1.1-compat -COPY --from=nim /src/nitter/nitter ./ -COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf -COPY --from=nim /src/nitter/public ./public -EXPOSE 8080 -RUN adduser -h /src/ -D -s /bin/sh nitter -USER nitter -CMD ./nitter diff --git a/docker-compose.yml b/docker-compose.yml index ec8ade5..9e2d996 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,19 @@ version: "3" -services: - +networks: nitter: - image: zedeus/nitter:latest + +services: + nitter: + build: . container_name: nitter + hostname: nitter ports: - - "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy + - "8002:8080" # Replace with "8080:8080" if you don't use a reverse proxy volumes: - ./nitter.conf:/src/nitter.conf:Z,ro + - ./guest_accounts.json:/src/guest_accounts.json:Z,ro + - ./public/.twitterminator.txt:/src/public/.twitterminator.txt:Z,ro depends_on: - nitter-redis restart: unless-stopped @@ -23,6 +28,8 @@ services: - no-new-privileges:true cap_drop: - ALL + networks: + - nitter nitter-redis: image: redis:6-alpine @@ -42,6 +49,8 @@ services: - no-new-privileges:true cap_drop: - ALL + networks: + - nitter volumes: nitter-redis: diff --git a/src/auth.nim b/src/auth.nim index b288c50..de1b1d8 100644 --- a/src/auth.nim +++ b/src/auth.nim @@ -202,7 +202,7 @@ proc initAccountPool*(cfg: Config; path: string) = quit 1 let accountsPrePurge = accountPool.len - accountPool.keepItIf(not it.hasExpired) + #accountPool.keepItIf(not it.hasExpired) log "Successfully added ", accountPool.len, " valid accounts." if accountsPrePurge > accountPool.len: diff --git a/src/nitter.nim b/src/nitter.nim index dfc1dfd..744eff9 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -10,7 +10,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth import views/[general, about] import routes/[ preferences, timeline, status, media, search, rss, list, debug, - unsupported, embed, resolver, router_utils] + twitter_api, unsupported, embed, resolver, router_utils] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -53,6 +53,7 @@ createSearchRouter(cfg) createMediaRouter(cfg) createEmbedRouter(cfg) createRssRouter(cfg) +createTwitterApiRouter(cfg) createDebugRouter(cfg) settings: diff --git a/src/routes/twitter_api.nim b/src/routes/twitter_api.nim new file mode 100644 index 0000000..0b8eef3 --- /dev/null +++ b/src/routes/twitter_api.nim @@ -0,0 +1,171 @@ +# SPDX-License-Identifier: AGPL-3.0-only + +import json, asyncdispatch, options, uri +import times +import jester +import router_utils +import ".."/[types, api, apiutils, query, consts] +import httpclient, strutils +import sequtils + +export api + +proc videoToJson*(t: Video): JsonNode = + result = newJObject() + result["durationMs"] = %t.durationMs + result["url"] = %t.url + result["thumb"] = %t.thumb + result["views"] = %t.views + result["available"] = %t.available + result["reason"] = %t.reason + result["title"] = %t.title + result["description"] = %t.description + # result["playbackType"] = %t.playbackType + # result["variants"] = %t.variants + # playbackType*: VideoType + # variants*: seq[VideoVariant] + +proc tweetToJson*(t: Tweet): JsonNode = + result = newJObject() + result["id"] = %t.id + result["threadId"] = %t.threadId + result["replyId"] = %t.replyId + result["user"] = %*{ "username": t.user.username } + result["text"] = %t.text + result["time"] = newJString(times.format(t.time, "yyyy-MM-dd'T'HH:mm:ss")) + result["reply"] = %t.reply + result["pinned"] = %t.pinned + result["hasThread"] = %t.hasThread + result["available"] = %t.available + result["tombstone"] = %t.tombstone + result["location"] = %t.location + result["source"] = %t.source + # result["stats"] = toJson(t.stats) # Define conversion for TweetStats type + # result["retweet"] = t.retweet.map(toJson) # Define conversion for Tweet type + # result["attribution"] = t.attribution.map(toJson) # Define conversion for User type + # result["mediaTags"] = toJson(t.mediaTags) # Define conversion for seq[User] + # result["quote"] = t.quote.map(toJson) # Define conversion for Tweet type + # result["card"] = t.card.map(toJson) # Define conversion for Card type + # result["poll"] = t.poll.map(toJson) # Define conversion for Poll type + # result["gif"] = t.gif.map(toJson) # Define conversion for Gif type + # result["video"] = videoToJson(t.video.get()) + result["photos"] = %t.photos + +proc getUserProfileJson*(username: string): Future[JsonNode] {.async.} = + let user: User = await getGraphUser(username) + let response: JsonNode = %*{ + "id": user.id, + "username": user.username + } + result = response + +proc getUserTweetsJson*(id: string): Future[JsonNode] {.async.} = + let tweetsGraph = await getGraphUserTweets(id, TimelineKind.tweets) + let repliesGraph = await getGraphUserTweets(id, TimelineKind.replies) + let mediaGraph = await getGraphUserTweets(id, TimelineKind.media) + + let tweetsContent = tweetsGraph.tweets.content[0] + let tweetsJson = tweetsContent.map(tweetToJson) + + let repliesContent = repliesGraph.tweets.content[0] + let repliesJson = repliesContent.map(tweetToJson) + + let mediaContent = mediaGraph.tweets.content[0] + let mediaJson = mediaContent.map(tweetToJson) + + let response: JsonNode = %*{ + "tweets": %tweetsJson, + "replies": %repliesJson, + "media": %mediaJson + } + + result = response + +proc searchTimeline*(query: Query; after=""): Future[string] {.async.} = + let q = genQueryParam(query) + var + variables = %*{ + "rawQuery": q, + "count": 20, + "product": "Latest", + "withDownvotePerspective": false, + "withReactionsMetadata": false, + "withReactionsPerspective": false + } + if after.len > 0: + variables["cursor"] = % after + let url = graphSearchTimeline ? {"variables": $variables, "features": gqlFeatures} + result = await fetchRaw(url, Api.search) + +proc getUserTweets*(id: string; after=""): Future[string] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + variables = userTweetsVariables % [id, cursor] + params = {"variables": variables, "features": gqlFeatures} + result = await fetchRaw(graphUserTweets ? params, Api.userTweets) + +proc getUserReplies*(id: string; after=""): Future[string] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + variables = userTweetsVariables % [id, cursor] + params = {"variables": variables, "features": gqlFeatures} + result = await fetchRaw(graphUserTweets ? params, Api.userTweetsAndReplies) + +proc getUserMedia*(id: string; after=""): Future[string] {.async.} = + if id.len == 0: return + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + variables = userTweetsVariables % [id, cursor] + params = {"variables": variables, "features": gqlFeatures} + result = await fetchRaw(graphUserTweets ? params, Api.userMedia) + +proc getTweetById*(id: string; after=""): Future[string] {.async.} = + let + cursor = if after.len > 0: "\"cursor\":\"$1\"," % after else: "" + variables = tweetVariables % [id, cursor] + params = {"variables": variables, "features": gqlFeatures} + result = await fetchRaw(graphTweet ? params, Api.tweetDetail) + +proc createTwitterApiRouter*(cfg: Config) = + router api: + get "/api/echo": + resp Http200, {"Content-Type": "text/html"}, "hello, world!" + + get "/api/user/@username": + let username = @"username" + let response = await getUserProfileJson(username) + respJson response + + # get "/api/user/@id/tweets": + # let id = @"id" + # let response = await getUserTweetsJson(id) + # respJson response + + get "/api/user/@username/timeline": + let username = @"username" + let query = Query(fromUser: @[username]) + let response = await searchTimeline(query) + resp Http200, { "Content-Type": "application/json" }, response + + get "/api/user/@id/tweets": + let id = @"id" + let after = getCursor() + let response = await getUserTweets(id, after) + resp Http200, { "Content-Type": "application/json" }, response + + get "/api/user/@id/replies": + let id = @"id" + let response = await getUserReplies(id) + resp Http200, { "Content-Type": "application/json" }, response + + get "/api/user/@id/media": + let id = @"id" + let response = await getUserMedia(id) + resp Http200, { "Content-Type": "application/json" }, response + + get "/api/tweet/@id": + let id = @"id" + let response = await getTweetById(id) + resp Http200, { "Content-Type": "application/json" }, response diff --git a/src/views/general.nim b/src/views/general.nim index 5ba40a3..35efb0b 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -32,8 +32,6 @@ proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode = if cfg.enableRss and rss.len > 0: icon "rss-feed", title="RSS Feed", href=rss icon "bird", title="Open in Twitter", href=canonical - a(href="https://liberapay.com/zedeus"): verbatim lp - icon "info", title="About", href="/about" icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path)) proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; diff --git a/src/views/profile.nim b/src/views/profile.nim index 2b2e410..8f67f5a 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -13,7 +13,7 @@ proc renderStat(num: int; class: string; text=""): VNode = text insertSep($num, ',') proc renderUserCard*(user: User; prefs: Prefs): VNode = - buildHtml(tdiv(class="profile-card")): + buildHtml(tdiv(class="profile-card", "data-profile-id" = $user.id)): tdiv(class="profile-card-info"): let url = getPicUrl(user.getUserPic())