├── tests ├── requirements.txt ├── test_search.py ├── test_thread.py ├── test_quote.py ├── test_timeline.py ├── test_profile.py ├── integration.py ├── base.py ├── test_card.py └── test_tweet_media.py ├── scripts ├── requirements.txt ├── nginx.sh ├── redis.sh ├── nitter.sh ├── dump_env_and_procfile.sh ├── gen_nginx_conf.py ├── assets │ └── nginx.conf └── gen_nitter_conf.py ├── .github ├── FUNDING.yml └── workflows │ ├── run-tests.yml │ ├── build-publish-docker.yml │ └── build-publish-self-contained-docker.yml ├── public ├── css │ ├── themes │ │ ├── nitter.css │ │ ├── auto.css │ │ ├── auto_(twitter).css │ │ ├── twitter.css │ │ ├── pleroma.css │ │ ├── black.css │ │ ├── mastodon.css │ │ ├── twitter_dark.css │ │ └── dracula.css │ └── fontello.css ├── logo.png ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── fonts │ ├── fontello.eot │ ├── fontello.ttf │ ├── fontello.woff │ ├── fontello.woff2 │ └── LICENSE.txt ├── apple-touch-icon.png ├── robots.txt ├── android-chrome-192x192.png ├── android-chrome-384x384.png ├── android-chrome-512x512.png ├── browserconfig.xml ├── site.webmanifest ├── safari-pinned-tab.svg ├── lp.svg ├── js │ ├── hlsPlayback.js │ └── infiniteScroll.js └── md │ └── about.md ├── screenshot.png ├── src ├── experimental │ ├── parser.nim │ ├── types │ │ ├── guestaccount.nim │ │ ├── graphuser.nim │ │ ├── common.nim │ │ ├── timeline.nim │ │ ├── graphlistmembers.nim │ │ ├── user.nim │ │ └── unifiedcard.nim │ └── parser │ │ ├── guestaccount.nim │ │ ├── utils.nim │ │ ├── graphql.nim │ │ ├── slices.nim │ │ ├── user.nim │ │ └── unifiedcard.nim ├── routes │ ├── debug.nim │ ├── unsupported.nim │ ├── resolver.nim │ ├── embed.nim │ ├── preferences.nim │ ├── router_utils.nim │ ├── search.nim │ ├── list.nim │ ├── status.nim │ ├── media.nim │ └── timeline.nim ├── sass │ ├── tweet │ │ ├── embed.scss │ │ ├── poll.scss │ │ ├── video.scss │ │ ├── quote.scss │ │ ├── card.scss │ │ ├── media.scss │ │ ├── thread.scss │ │ └── _base.scss │ ├── general.scss │ ├── include │ │ ├── _variables.scss │ │ └── _mixins.css │ ├── profile │ │ ├── _base.scss │ │ ├── photo-rail.scss │ │ └── card.scss │ ├── navbar.scss │ ├── search.scss │ ├── timeline.scss │ ├── inputs.scss │ └── index.scss ├── prefs.nim ├── views │ ├── feature.nim │ ├── opensearch.nimf │ ├── embed.nim │ ├── about.nim │ ├── list.nim │ ├── status.nim │ ├── renderutils.nim │ ├── preferences.nim │ ├── profile.nim │ ├── search.nim │ └── timeline.nim ├── http_pool.nim ├── utils.nim ├── config.nim ├── query.nim ├── nitter.nim └── apiutils.nim ├── .dockerignore ├── twitter-credentials.example.json ├── tools ├── gencss.nim └── rendermd.nim ├── README.md ├── .devcontainer ├── init.sh └── devcontainer.json ├── config.nims ├── .gitignore ├── fly.example.toml ├── original.Dockerfile ├── nitter.nimble ├── Dockerfile ├── docker-compose.yml └── nitter.example.conf /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | seleniumbase 2 | -------------------------------------------------------------------------------- /scripts/requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | passlib -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: zedeus 2 | liberapay: zedeus 3 | patreon: nitter 4 | -------------------------------------------------------------------------------- /public/css/themes/nitter.css: -------------------------------------------------------------------------------- 1 | body { 2 | /* uses default values */ 3 | } 4 | -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/public/logo.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/screenshot.png -------------------------------------------------------------------------------- /src/experimental/parser.nim: -------------------------------------------------------------------------------- 1 | import parser/[user, graphql] 2 | export user, graphql 3 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | *.png 2 | *.md 3 | LICENSE 4 | docker-compose.yml 5 | Dockerfile 6 | tests/ 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/fonts/fontello.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/public/fonts/fontello.eot -------------------------------------------------------------------------------- /public/fonts/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/public/fonts/fontello.ttf -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/fonts/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/public/fonts/fontello.woff -------------------------------------------------------------------------------- /public/fonts/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/public/fonts/fontello.woff2 -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | Crawl-delay: 1 4 | User-agent: Twitterbot 5 | Disallow: 6 | -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/public/android-chrome-384x384.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sekai-soft/nitter/HEAD/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/css/themes/auto.css: -------------------------------------------------------------------------------- 1 | @import "nitter.css" (prefers-color-scheme: dark); 2 | @import "twitter.css" (prefers-color-scheme: light); 3 | -------------------------------------------------------------------------------- /src/experimental/types/guestaccount.nim: -------------------------------------------------------------------------------- 1 | type 2 | RawAccount* = object 3 | oauthToken*: string 4 | oauthTokenSecret*: string 5 | -------------------------------------------------------------------------------- /public/css/themes/auto_(twitter).css: -------------------------------------------------------------------------------- 1 | @import "twitter_dark.css" (prefers-color-scheme: dark); 2 | @import "twitter.css" (prefers-color-scheme: light); 3 | -------------------------------------------------------------------------------- /twitter-credentials.example.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "username": "", 4 | "password": "" 5 | }, 6 | { 7 | "username": "", 8 | "password": "" 9 | } 10 | ] -------------------------------------------------------------------------------- /scripts/nginx.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo Running gen_nginx_conf... 5 | python3 /src/scripts/gen_nginx_conf.py /etc/nginx/conf.d/nitter.conf /etc/nginx/.htpasswd 6 | 7 | echo Launching nginx... 8 | nginx 9 | -------------------------------------------------------------------------------- /tools/gencss.nim: -------------------------------------------------------------------------------- 1 | import sass 2 | 3 | compileFile("src/sass/index.scss", 4 | outputPath = "public/css/style.css", 5 | includePaths = @["src/sass/include"]) 6 | 7 | echo "Compiled to public/css/style.css" 8 | -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | #2b5797 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This fork has been deprecated by a new closer-to-upstream fork [here](https://github.com/sekai-soft/nitter-pitchforked) 2 | 3 | Please direct questions to [sekai-soft/guide-nitter-self-hosting](https://github.com/sekai-soft/guide-nitter-self-hosting/issues). Thank you. 4 | -------------------------------------------------------------------------------- /tools/rendermd.nim: -------------------------------------------------------------------------------- 1 | import std/[os, strutils] 2 | import markdown 3 | 4 | for file in walkFiles("public/md/*.md"): 5 | let 6 | html = markdown(readFile(file)) 7 | output = file.replace(".md", ".html") 8 | 9 | output.writeFile(html) 10 | echo "Rendered ", output 11 | -------------------------------------------------------------------------------- /.devcontainer/init.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sudo apt update 4 | sudo apt install -y python3-pip redis libsass-dev 5 | 6 | pip3 install -r ./scripts/requirements.txt 7 | 8 | curl https://nim-lang.org/choosenim/init.sh -sSf | sh 9 | echo 'export PATH=/home/vscode/.nimble/bin:$PATH' >> ~/.bashrc 10 | -------------------------------------------------------------------------------- /config.nims: -------------------------------------------------------------------------------- 1 | --define:ssl 2 | --define:useStdLib 3 | --threads:off 4 | 5 | # workaround httpbeast file upload bug 6 | --assertions:off 7 | 8 | # disable annoying warnings 9 | warning("GcUnsafe2", off) 10 | warning("HoleEnumConv", off) 11 | hint("XDeclaredButNotUsed", off) 12 | hint("XCannotRaiseY", off) 13 | hint("User", off) 14 | -------------------------------------------------------------------------------- /scripts/redis.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo Checking if /nitter-data mount exists... 5 | if [ ! -d "/nitter-data" ]; then 6 | echo "/nitter-data does not exist" 7 | exit 1 8 | fi 9 | 10 | echo Creating redis data dir just in case... 11 | mkdir -p /nitter-data/redis 12 | 13 | echo Launching redis... 14 | redis-server /src/redis.conf 15 | -------------------------------------------------------------------------------- /tests/test_search.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase 2 | from parameterized import parameterized 3 | 4 | 5 | #class SearchTest(BaseTestCase): 6 | #@parameterized.expand([['@mobile_test'], ['@mobile_test_2']]) 7 | #def test_username_search(self, username): 8 | #self.search_username(username) 9 | #self.assert_text(f'{username}') 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | nitter 2 | *.html 3 | *.db 4 | /tests/__pycache__ 5 | /tests/geckodriver.log 6 | /tests/downloaded_files 7 | /tests/latest_logs 8 | /tools/gencss 9 | /tools/rendermd 10 | /public/css/style.css 11 | /public/md/*.html 12 | nitter.conf 13 | guest_accounts.json* 14 | dump.rdb 15 | .env 16 | twitter-credentials.json 17 | integration-test.logs 18 | fly.toml 19 | -------------------------------------------------------------------------------- /src/routes/debug.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import jester 3 | import router_utils 4 | import ".."/[auth, types] 5 | 6 | proc createDebugRouter*(cfg: Config) = 7 | router debug: 8 | get "/.health": 9 | respJson getAccountPoolHealth() 10 | 11 | get "/.accounts": 12 | cond cfg.enableDebug 13 | respJson getAccountPoolDebug() 14 | -------------------------------------------------------------------------------- /src/sass/tweet/embed.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | .embed-video { 5 | .gallery-video { 6 | width: 100%; 7 | height: 100%; 8 | position: absolute; 9 | background-color: black; 10 | top: 0%; 11 | left: 0%; 12 | } 13 | 14 | .video-container { 15 | max-height: unset; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/experimental/types/graphuser.nim: -------------------------------------------------------------------------------- 1 | import options 2 | from ../../types import User 3 | 4 | type 5 | GraphUser* = object 6 | data*: tuple[userResult: UserData] 7 | 8 | UserData* = object 9 | result*: UserResult 10 | 11 | UserResult = object 12 | legacy*: User 13 | restId*: string 14 | isBlueVerified*: bool 15 | unavailableReason*: Option[string] 16 | -------------------------------------------------------------------------------- /src/experimental/types/common.nim: -------------------------------------------------------------------------------- 1 | from ../../types import Error 2 | 3 | type 4 | Url* = object 5 | url*: string 6 | expandedUrl*: string 7 | displayUrl*: string 8 | indices*: array[2, int] 9 | 10 | ErrorObj* = object 11 | code*: Error 12 | message*: string 13 | 14 | Errors* = object 15 | errors*: seq[ErrorObj] 16 | 17 | proc contains*(codes: set[Error]; errors: Errors): bool = 18 | for e in errors.errors: 19 | if e.code in codes: 20 | return true 21 | -------------------------------------------------------------------------------- /src/experimental/types/timeline.nim: -------------------------------------------------------------------------------- 1 | import std/tables 2 | from ../../types import User 3 | 4 | type 5 | Search* = object 6 | globalObjects*: GlobalObjects 7 | timeline*: Timeline 8 | 9 | GlobalObjects = object 10 | users*: Table[string, User] 11 | 12 | Timeline = object 13 | instructions*: seq[Instructions] 14 | 15 | Instructions = object 16 | addEntries*: tuple[entries: seq[Entry]] 17 | 18 | Entry = object 19 | entryId*: string 20 | content*: tuple[operation: Operation] 21 | 22 | Operation = object 23 | cursor*: tuple[value, cursorType: string] 24 | -------------------------------------------------------------------------------- /fly.example.toml: -------------------------------------------------------------------------------- 1 | app = 'nitter' 2 | kill_signal = 'SIGINT' 3 | kill_timeout = '5s' 4 | 5 | [build] 6 | image = 'ghcr.io/sekai-soft/nitter-self-contained:latest' 7 | 8 | [[mounts]] 9 | source = 'nitter' 10 | destination = '/nitter-data' 11 | initial_size = '1gb' 12 | 13 | [env] 14 | NITTER_ACCOUNTS_FILE = "/nitter-data/guest_accounts.json" 15 | 16 | [http_service] 17 | internal_port = 8081 18 | force_https = true 19 | auto_stop_machines = "off" 20 | auto_start_machines = false 21 | min_machines_running = 1 22 | 23 | [[vm]] 24 | memory = '256mb' 25 | cpu_kind = 'shared' 26 | cpus = 1 27 | -------------------------------------------------------------------------------- /src/prefs.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import tables 3 | import types, prefs_impl 4 | from config import get 5 | from parsecfg import nil 6 | 7 | export genUpdatePrefs, genResetPrefs 8 | 9 | var defaultPrefs*: Prefs 10 | 11 | proc updateDefaultPrefs*(cfg: parsecfg.Config) = 12 | genDefaultPrefs() 13 | 14 | proc getPrefs*(cookies: Table[string, string]): Prefs = 15 | result = defaultPrefs 16 | genCookiePrefs(cookies) 17 | 18 | template getPref*(cookies: Table[string, string], pref): untyped = 19 | bind genCookiePref 20 | var res = defaultPrefs.`pref` 21 | genCookiePref(cookies, pref, res) 22 | res 23 | -------------------------------------------------------------------------------- /src/views/feature.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import karax/[karaxdsl, vdom] 3 | 4 | proc renderFeature*(): VNode = 5 | buildHtml(tdiv(class="overlay-panel")): 6 | h1: text "Unsupported feature" 7 | p: 8 | text "Nitter doesn't support this feature yet, but it might in the future. " 9 | text "You can check for an issue and open one if needed here: " 10 | a(href="https://github.com/zedeus/nitter/issues"): 11 | text "https://github.com/zedeus/nitter/issues" 12 | p: 13 | text "To find out more about the Nitter project, see the " 14 | a(href="/about"): text "About page" 15 | -------------------------------------------------------------------------------- /src/views/opensearch.nimf: -------------------------------------------------------------------------------- 1 | #? stdtmpl(subsChar = '$', metaChar = '#') 2 | ## SPDX-License-Identifier: AGPL-3.0-only 3 | #proc generateOpenSearchXML*(name, hostname, url: string): string = 4 | # result = "" 5 | 6 | 8 | ${name} 9 | Twitter search via ${hostname} 10 | UTF-8 11 | 12 | 13 | #end proc 14 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Nitter", 3 | "short_name": "Nitter", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-384x384.png", 12 | "sizes": "384x384", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/android-chrome-512x512.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "theme_color": "#333333", 22 | "background_color": "#333333", 23 | "display": "standalone" 24 | } 25 | -------------------------------------------------------------------------------- /src/routes/unsupported.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import jester 3 | 4 | import router_utils 5 | import ../types 6 | import ../views/[general, feature] 7 | 8 | export feature 9 | 10 | proc createUnsupportedRouter*(cfg: Config) = 11 | router unsupported: 12 | template feature {.dirty.} = 13 | resp renderMain(renderFeature(), request, cfg, themePrefs()) 14 | 15 | get "/about/feature": feature() 16 | get "/login/?@i?": feature() 17 | get "/@name/lists/?": feature() 18 | 19 | get "/intent/?@i?": 20 | cond @"i" notin ["user"] 21 | feature() 22 | 23 | get "/i/@i?/?@j?": 24 | cond @"i" notin ["status", "lists" , "user"] 25 | feature() 26 | -------------------------------------------------------------------------------- /src/views/embed.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import options 3 | import karax/[karaxdsl, vdom] 4 | from jester import Request 5 | 6 | import ".."/[types, formatters] 7 | import general, tweet 8 | 9 | const doctype = "\n" 10 | 11 | proc renderVideoEmbed*(tweet: Tweet; cfg: Config; req: Request): string = 12 | let thumb = get(tweet.video).thumb 13 | let vidUrl = getVideoEmbed(cfg, tweet.id) 14 | let prefs = Prefs(hlsPlayback: true, mp4Playback: true) 15 | let node = buildHtml(html(lang="en")): 16 | renderHead(prefs, cfg, req, video=vidUrl, images=(@[thumb])) 17 | 18 | body: 19 | tdiv(class="embed-video"): 20 | renderVideo(get(tweet.video), prefs, "") 21 | 22 | result = doctype & $node 23 | -------------------------------------------------------------------------------- /original.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18 as nim 2 | LABEL maintainer="setenforce@protonmail.com" 3 | 4 | RUN apk --no-cache add libsass-dev pcre gcc git libc-dev "nim=1.6.14-r0" "nimble=0.13.1-r2" 5 | 6 | WORKDIR /src/nitter 7 | 8 | COPY nitter.nimble . 9 | RUN nimble install -y --depsOnly 10 | 11 | COPY . . 12 | RUN nimble build -d:danger -d:lto -d:strip \ 13 | && nimble scss \ 14 | && nimble md 15 | 16 | FROM alpine:3.18 17 | WORKDIR /src/ 18 | RUN apk --no-cache add pcre ca-certificates openssl1.1-compat 19 | COPY --from=nim /src/nitter/nitter ./ 20 | COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf 21 | COPY --from=nim /src/nitter/public ./public 22 | EXPOSE 8080 23 | RUN adduser -h /src/ -D -s /bin/sh nitter 24 | USER nitter 25 | CMD ./nitter 26 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/routes/resolver.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils 3 | 4 | import jester 5 | 6 | import router_utils 7 | import ".."/[types, api] 8 | import ../views/general 9 | 10 | template respResolved*(url, kind: string): untyped = 11 | let u = url 12 | if u.len == 0: 13 | resp showError("Invalid $1 link" % kind, cfg) 14 | else: 15 | redirect(u) 16 | 17 | proc createResolverRouter*(cfg: Config) = 18 | router resolver: 19 | get "/cards/@card/@id": 20 | let url = "https://cards.twitter.com/cards/$1/$2" % [@"card", @"id"] 21 | respResolved(await resolve(url, cookiePrefs()), "card") 22 | 23 | get "/t.co/@url": 24 | let url = "https://t.co/" & @"url" 25 | respResolved(await resolve(url, cookiePrefs()), "t.co") 26 | -------------------------------------------------------------------------------- /src/experimental/parser/guestaccount.nim: -------------------------------------------------------------------------------- 1 | import std/strutils 2 | import jsony 3 | import ../types/guestaccount 4 | from ../../types import GuestAccount 5 | 6 | proc toGuestAccount(account: RawAccount): GuestAccount = 7 | let id = account.oauthToken[0 ..< account.oauthToken.find('-')] 8 | result = GuestAccount( 9 | id: parseBiggestInt(id), 10 | oauthToken: account.oauthToken, 11 | oauthSecret: account.oauthTokenSecret 12 | ) 13 | 14 | proc parseGuestAccount*(raw: string): GuestAccount = 15 | let rawAccount = raw.fromJson(RawAccount) 16 | result = rawAccount.toGuestAccount 17 | 18 | proc parseGuestAccounts*(path: string): seq[GuestAccount] = 19 | let rawAccounts = readFile(path).fromJson(seq[RawAccount]) 20 | for account in rawAccounts: 21 | result.add account.toGuestAccount 22 | -------------------------------------------------------------------------------- /src/experimental/parser/utils.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import std/[sugar, strutils, times] 3 | import ../types/common 4 | import ../../utils as uutils 5 | 6 | template parseTime(time: string; f: static string; flen: int): DateTime = 7 | if time.len != flen: return 8 | parse(time, f, utc()) 9 | 10 | proc parseIsoDate*(date: string): DateTime = 11 | date.parseTime("yyyy-MM-dd\'T\'HH:mm:ss\'Z\'", 20) 12 | 13 | proc parseTwitterDate*(date: string): DateTime = 14 | date.parseTime("ddd MMM dd hh:mm:ss \'+0000\' yyyy", 30) 15 | 16 | proc getImageUrl*(url: string): string = 17 | url.dup(removePrefix(twimg), removePrefix(https)) 18 | 19 | template handleErrors*(body) = 20 | if json.startsWith("{\"errors"): 21 | for error {.inject.} in json.fromJson(Errors).errors: 22 | body 23 | -------------------------------------------------------------------------------- /public/lp.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/experimental/types/graphlistmembers.nim: -------------------------------------------------------------------------------- 1 | import graphuser 2 | 3 | type 4 | GraphListMembers* = object 5 | data*: tuple[list: List] 6 | 7 | List = object 8 | membersTimeline*: tuple[timeline: Timeline] 9 | 10 | Timeline = object 11 | instructions*: seq[Instruction] 12 | 13 | Instruction = object 14 | kind*: string 15 | entries*: seq[tuple[content: Content]] 16 | 17 | ContentEntryType* = enum 18 | TimelineTimelineItem 19 | TimelineTimelineCursor 20 | 21 | Content = object 22 | case entryType*: ContentEntryType 23 | of TimelineTimelineItem: 24 | itemContent*: tuple[userResults: UserData] 25 | of TimelineTimelineCursor: 26 | value*: string 27 | cursorType*: string 28 | 29 | proc renameHook*(v: var Instruction; fieldName: var string) = 30 | if fieldName == "type": 31 | fieldName = "kind" 32 | -------------------------------------------------------------------------------- /scripts/nitter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo Running auth... 5 | python /src/scripts/auth.py /nitter-data/guest_accounts.json 6 | 7 | if [ "$USE_CUSTOM_CONF" == "1" ]; then 8 | echo Using custom conf. Make sure /src/nitter.conf exists. 9 | else 10 | echo Generating nitter conf... 11 | python /src/scripts/gen_nitter_conf.py /src/nitter.conf 12 | fi 13 | 14 | if [ "$DISABLE_REDIS" != "1" ]; then 15 | echo Waiting for redis... 16 | counter=0 17 | while ! redis-cli ping; do 18 | sleep 1 19 | counter=$((counter+1)) 20 | if [ $counter -ge 30 ]; then 21 | echo "Redis was not ready after 30 seconds, exiting" 22 | exit 1 23 | fi 24 | done 25 | else 26 | echo Redis was not provisioned inside container. An external orchestrator should have ensured Redis is available. 27 | fi 28 | 29 | echo Launching nitter... 30 | /src/nitter 31 | -------------------------------------------------------------------------------- /src/views/about.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import os, strformat 3 | import karax/[karaxdsl, vdom] 4 | 5 | const 6 | date = staticExec("git show -s --format=\"%cd\" --date=format:\"%Y.%m.%d\"") 7 | hash = staticExec("git show -s --format=\"%h\"") 8 | link = "https://github.com/zedeus/nitter/commit/" & hash 9 | version = &"{date}-{hash}" 10 | 11 | var aboutHtml: string 12 | 13 | proc initAboutPage*(dir: string) = 14 | try: 15 | aboutHtml = readFile(dir/"md/about.html") 16 | except IOError: 17 | stderr.write (dir/"md/about.html") & " not found, please run `nimble md`\n" 18 | aboutHtml = "

About page is missing



" 19 | 20 | proc renderAbout*(): VNode = 21 | buildHtml(tdiv(class="overlay-panel")): 22 | verbatim aboutHtml 23 | h2: text "Instance info" 24 | p: 25 | text "Version " 26 | a(href=link): text version 27 | -------------------------------------------------------------------------------- /src/sass/tweet/poll.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | .poll-meter { 4 | overflow: hidden; 5 | position: relative; 6 | margin: 6px 0; 7 | height: 26px; 8 | background: var(--bg_color); 9 | border-radius: 5px; 10 | display: flex; 11 | align-items: center; 12 | } 13 | 14 | .poll-choice-bar { 15 | height: 100%; 16 | position: absolute; 17 | background: var(--dark_grey); 18 | } 19 | 20 | .poll-choice-value { 21 | position: relative; 22 | font-weight: bold; 23 | margin-left: 5px; 24 | margin-right: 6px; 25 | min-width: 30px; 26 | text-align: right; 27 | pointer-events: all; 28 | } 29 | 30 | .poll-choice-option { 31 | position: relative; 32 | pointer-events: all; 33 | } 34 | 35 | .poll-info { 36 | color: var(--grey); 37 | pointer-events: all; 38 | } 39 | 40 | .leader .poll-choice-bar { 41 | background: var(--accent_dark); 42 | } 43 | -------------------------------------------------------------------------------- /src/sass/general.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | .panel-container { 5 | margin: auto; 6 | font-size: 130%; 7 | } 8 | 9 | .error-panel { 10 | @include center-panel(var(--error_red)); 11 | text-align: center; 12 | } 13 | 14 | .search-bar > form { 15 | @include center-panel(var(--darkest_grey)); 16 | 17 | button { 18 | background: var(--bg_elements); 19 | color: var(--fg_color); 20 | border: 0; 21 | border-radius: 3px; 22 | cursor: pointer; 23 | font-weight: bold; 24 | width: 30px; 25 | height: 30px; 26 | } 27 | 28 | input { 29 | font-size: 16px; 30 | width: 100%; 31 | background: var(--bg_elements); 32 | color: var(--fg_color); 33 | border: 0; 34 | border-radius: 4px; 35 | padding: 4px; 36 | margin-right: 8px; 37 | height: unset; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /public/js/hlsPlayback.js: -------------------------------------------------------------------------------- 1 | // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 2 | // SPDX-License-Identifier: AGPL-3.0-only 3 | function playVideo(overlay) { 4 | const video = overlay.parentElement.querySelector('video'); 5 | const url = video.getAttribute("data-url"); 6 | video.setAttribute("controls", ""); 7 | overlay.style.display = "none"; 8 | 9 | if (Hls.isSupported()) { 10 | var hls = new Hls({autoStartLoad: false}); 11 | hls.loadSource(url); 12 | hls.attachMedia(video); 13 | hls.on(Hls.Events.MANIFEST_PARSED, function () { 14 | hls.loadLevel = hls.levels.length - 1; 15 | hls.startLoad(); 16 | video.play(); 17 | }); 18 | } else if (video.canPlayType('application/vnd.apple.mpegurl')) { 19 | video.src = url; 20 | video.addEventListener('canplay', function() { 21 | video.play(); 22 | }); 23 | } 24 | } 25 | // @license-end 26 | -------------------------------------------------------------------------------- /public/css/themes/twitter.css: -------------------------------------------------------------------------------- 1 | body { 2 | --bg_color: #E6ECF0; 3 | --fg_color: #0F0F0F; 4 | --fg_faded: #657786; 5 | --fg_dark: var(--fg_faded); 6 | --fg_nav: var(--accent); 7 | 8 | --bg_panel: #FFFFFF; 9 | --bg_elements: #FDFDFD; 10 | --bg_overlays: #FFFFFF; 11 | --bg_hover: #F5F8FA; 12 | 13 | --grey: var(--fg_faded); 14 | --dark_grey: #D6D6D6; 15 | --darker_grey: #CECECE; 16 | --darkest_grey: #ECECEC; 17 | --border_grey: #E6ECF0; 18 | 19 | --accent: #1DA1F2; 20 | --accent_light: #A0EDFF; 21 | --accent_dark: var(--accent); 22 | --accent_border: #1DA1F296; 23 | 24 | --play_button: #D84D4D; 25 | --play_button_hover: #FF6C60; 26 | 27 | --more_replies_dots: #0199F7; 28 | --error_red: #FF7266; 29 | 30 | --verified_blue: var(--accent); 31 | --icon_text: #F8F8F2; 32 | 33 | --tab: var(--accent); 34 | --tab_selected: #000000; 35 | 36 | --profile_stat: var(--fg_dark); 37 | } 38 | -------------------------------------------------------------------------------- /public/css/themes/pleroma.css: -------------------------------------------------------------------------------- 1 | body { 2 | --bg_color: #0A1117; 3 | --fg_color: #B9B9BA; 4 | --fg_faded: #B9B9BAFA; 5 | --fg_dark: var(--accent); 6 | --fg_nav: var(--accent); 7 | 8 | --bg_panel: #121A24; 9 | --bg_elements: #182230; 10 | --bg_overlays: var(--bg_elements); 11 | --bg_hover: #1B2735; 12 | 13 | --grey: #666A6F; 14 | --dark_grey: #42413D; 15 | --darker_grey: #293442; 16 | --darkest_grey: #202935; 17 | --border_grey: #1C2737; 18 | 19 | --accent: #D8A070; 20 | --accent_light: #DEB897; 21 | --accent_dark: #6D533C; 22 | --accent_border: #947050; 23 | 24 | --play_button: var(--accent); 25 | --play_button_hover: var(--accent_light); 26 | 27 | --more_replies_dots: #886446; 28 | --error_red: #420A05; 29 | 30 | --verified_blue: #1DA1F2; 31 | --icon_text: #F8F8F8; 32 | 33 | --tab: var(--fg_color); 34 | --tab_selected: var(--accent); 35 | 36 | --profile_stat: var(--fg_color); 37 | } 38 | -------------------------------------------------------------------------------- /public/css/themes/black.css: -------------------------------------------------------------------------------- 1 | body { 2 | --bg_color: #000000; 3 | --fg_color: #FFFFFF; 4 | --fg_faded: #FFFFFFD4; 5 | --fg_dark: var(--accent); 6 | --fg_nav: var(--accent); 7 | 8 | --bg_panel: #0C0C0C; 9 | --bg_elements: #000000; 10 | --bg_overlays: var(--bg_panel); 11 | --bg_hover: #131313; 12 | 13 | --grey: #E2E2E2; 14 | --dark_grey: #4E4E4E; 15 | --darker_grey: #272727; 16 | --darkest_grey: #212121; 17 | --border_grey: #7D7D7D; 18 | 19 | --accent: #FF6C60; 20 | --accent_light: #FFACA0; 21 | --accent_dark: #909090; 22 | --accent_border: #FF6C6091; 23 | 24 | --play_button: var(--accent); 25 | --play_button_hover: var(--accent_light); 26 | 27 | --more_replies_dots: #A7A7A7; 28 | --error_red: #420A05; 29 | 30 | --verified_blue: #1DA1F2; 31 | --icon_text: var(--fg_color); 32 | 33 | --tab: var(--fg_color); 34 | --tab_selected: var(--accent); 35 | 36 | --profile_stat: var(--fg_color); 37 | } 38 | -------------------------------------------------------------------------------- /nitter.nimble: -------------------------------------------------------------------------------- 1 | # Package 2 | 3 | version = "0.1.0" 4 | author = "zedeus" 5 | description = "An alternative front-end for Twitter" 6 | license = "AGPL-3.0" 7 | srcDir = "src" 8 | bin = @["nitter"] 9 | 10 | 11 | # Dependencies 12 | 13 | requires "nim >= 1.6.10" 14 | requires "jester#baca3f" 15 | requires "karax#5cf360c" 16 | requires "sass#7dfdd03" 17 | requires "nimcrypto#a079df9" 18 | requires "markdown#158efe3" 19 | requires "packedjson#9e6fbb6" 20 | requires "supersnappy#6c94198" 21 | requires "redpool#8b7c1db" 22 | requires "https://github.com/zedeus/redis#d0a0e6f" 23 | requires "zippy#ca5989a" 24 | requires "flatty#e668085" 25 | requires "jsony#1de1f08" 26 | requires "oauth#b8c163b" 27 | requires "https://github.com/iffy/nim-sentry.git" 28 | 29 | # Tasks 30 | 31 | task scss, "Generate css": 32 | exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss" 33 | 34 | task md, "Render md": 35 | exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd" 36 | -------------------------------------------------------------------------------- /public/css/themes/mastodon.css: -------------------------------------------------------------------------------- 1 | body { 2 | --bg_color: #191B22; 3 | --fg_color: #FFFFFF; 4 | --fg_faded: #F8F8F2CF; 5 | --fg_dark: var(--accent); 6 | --fg_nav: var(--accent); 7 | 8 | --bg_panel: #282C37; 9 | --bg_elements: #22252F; 10 | --bg_overlays: var(--bg_panel); 11 | --bg_hover: var(--bg_elements); 12 | 13 | --grey: #8595AB; 14 | --dark_grey: #393F4F; 15 | --darker_grey: #1C1E23; 16 | --darkest_grey: #1F2125; 17 | --border_grey: #393F4F; 18 | 19 | --accent: #9BAEC8; 20 | --accent_light: #CDDEF5; 21 | --accent_dark: #748294; 22 | --accent_border: var(--accent_dark); 23 | 24 | --play_button: var(--accent); 25 | --play_button_hover: var(--accent_light); 26 | 27 | --more_replies_dots: #7F8DA0; 28 | --error_red: #420A05; 29 | 30 | --verified_blue: #1DA1F2; 31 | --icon_text: var(--fg_color); 32 | 33 | --tab: var(--accent); 34 | --tab_selected: #D9E1E8; 35 | 36 | --profile_stat: var(--fg_color); 37 | } 38 | -------------------------------------------------------------------------------- /src/views/list.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strformat 3 | import karax/[karaxdsl, vdom] 4 | 5 | import renderutils 6 | import ".."/[types, utils] 7 | 8 | proc renderListTabs*(query: Query; path: string): VNode = 9 | buildHtml(ul(class="tab")): 10 | li(class=query.getTabClass(posts)): 11 | a(href=(path)): text "Tweets" 12 | li(class=query.getTabClass(userList)): 13 | a(href=(path & "/members")): text "Members" 14 | 15 | proc renderList*(body: VNode; query: Query; list: List): VNode = 16 | buildHtml(tdiv(class="timeline-container")): 17 | if list.banner.len > 0: 18 | tdiv(class="timeline-banner"): 19 | a(href=getPicUrl(list.banner), target="_blank"): 20 | genImg(list.banner) 21 | 22 | tdiv(class="timeline-header"): 23 | text &"\"{list.name}\" by @{list.username}" 24 | 25 | tdiv(class="timeline-description"): 26 | text list.description 27 | 28 | renderListTabs(query, &"/i/lists/{list.id}") 29 | body 30 | -------------------------------------------------------------------------------- /public/css/themes/twitter_dark.css: -------------------------------------------------------------------------------- 1 | body { 2 | --bg_color: #101821; 3 | --fg_color: #FFFFFF; 4 | --fg_faded: #8899A6; 5 | --fg_dark: var(--fg_faded); 6 | --fg_nav: var(--accent); 7 | 8 | --bg_panel: #15202B; 9 | --bg_elements: var(--bg_panel); 10 | --bg_overlays: var(--bg_panel); 11 | --bg_hover: #192734; 12 | 13 | --grey: var(--fg_faded); 14 | --dark_grey: #38444D; 15 | --darker_grey: #2A343C; 16 | --darkest_grey:#1B2835; 17 | --border_grey: #38444D; 18 | 19 | --accent: #1B95E0; 20 | --accent_light: #80CEFF; 21 | --accent_dark: #2B608A; 22 | --accent_border: #1B95E096; 23 | 24 | --play_button: var(--accent); 25 | --play_button_hover: var(--accent_light); 26 | 27 | --more_replies_dots: #39719C; 28 | --error_red: #FF7266; 29 | 30 | --verified_blue: var(--accent); 31 | --icon_text: var(--fg_color); 32 | 33 | --tab: var(--grey); 34 | --tab_selected: var(--accent); 35 | 36 | --profile_stat: var(--fg_color); 37 | } 38 | -------------------------------------------------------------------------------- /src/sass/include/_variables.scss: -------------------------------------------------------------------------------- 1 | // colors 2 | $bg_color: #0F0F0F; 3 | $fg_color: #F8F8F2; 4 | $fg_faded: #F8F8F2CF; 5 | $fg_dark: #FF6C60; 6 | $fg_nav: #FF6C60; 7 | 8 | $bg_panel: #161616; 9 | $bg_elements: #121212; 10 | $bg_overlays: #1F1F1F; 11 | $bg_hover: #1A1A1A; 12 | 13 | $grey: #888889; 14 | $dark_grey: #404040; 15 | $darker_grey: #282828; 16 | $darkest_grey: #222222; 17 | $border_grey: #3E3E35; 18 | 19 | $accent: #FF6C60; 20 | $accent_light: #FFACA0; 21 | $accent_dark: #8A3731; 22 | $accent_border: #FF6C6091; 23 | 24 | $play_button: #D8574D; 25 | $play_button_hover: #FF6C60; 26 | 27 | $more_replies_dots: #AD433B; 28 | $error_red: #420A05; 29 | 30 | $verified_blue: #1DA1F2; 31 | $verified_business: #FAC82B; 32 | $verified_government: #C1B6A4; 33 | $icon_text: $fg_color; 34 | 35 | $tab: $fg_color; 36 | $tab_selected: $accent; 37 | 38 | $shadow: rgba(0,0,0,.6); 39 | $shadow_dark: rgba(0,0,0,.2); 40 | 41 | //fonts 42 | $font_0: Helvetica Neue; 43 | $font_1: Helvetica; 44 | $font_2: Arial; 45 | $font_3: sans-serif; 46 | $font_4: fontello; 47 | -------------------------------------------------------------------------------- /public/css/themes/dracula.css: -------------------------------------------------------------------------------- 1 | body { 2 | --bg_color: #282a36; 3 | --fg_color: #f8f8f2; 4 | --fg_faded: #818eb6; 5 | --fg_dark: var(--fg_faded); 6 | --fg_nav: var(--accent); 7 | 8 | --bg_panel: #343746; 9 | --bg_elements: #292b36; 10 | --bg_overlays: #44475a; 11 | --bg_hover: #2f323f; 12 | 13 | --grey: var(--fg_faded); 14 | --dark_grey: #44475a; 15 | --darker_grey: #3d4051; 16 | --darkest_grey: #363948; 17 | --border_grey: #44475a; 18 | 19 | --accent: #bd93f9; 20 | --accent_light: #caa9fa; 21 | --accent_dark: var(--accent); 22 | --accent_border: #ff79c696; 23 | 24 | --play_button: #ffb86c; 25 | --play_button_hover: #ffc689; 26 | 27 | --more_replies_dots: #bd93f9; 28 | --error_red: #ff5555; 29 | 30 | --verified_blue: var(--accent); 31 | --icon_text: ##F8F8F2; 32 | 33 | --tab: #6272a4; 34 | --tab_selected: var(--accent); 35 | 36 | --profile_stat: #919cbf; 37 | } 38 | 39 | .search-bar > form input::placeholder{ 40 | color: var(--fg_faded); 41 | } -------------------------------------------------------------------------------- /src/routes/embed.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import asyncdispatch, strutils, strformat, options 3 | import jester, karax/vdom 4 | import ".."/[types, api] 5 | import ../views/[embed, tweet, general] 6 | import router_utils 7 | 8 | export api, embed, vdom, tweet, general, router_utils 9 | 10 | proc createEmbedRouter*(cfg: Config) = 11 | router embed: 12 | get "/i/videos/tweet/@id": 13 | let tweet = await getGraphTweetResult(@"id") 14 | if tweet == nil or tweet.video.isNone: 15 | resp Http404 16 | 17 | resp renderVideoEmbed(tweet, cfg, request) 18 | 19 | get "/@user/status/@id/embed": 20 | let 21 | tweet = await getGraphTweetResult(@"id") 22 | prefs = cookiePrefs() 23 | path = getPath() 24 | 25 | if tweet == nil: 26 | resp Http404 27 | 28 | resp renderTweetEmbed(tweet, path, prefs, cfg, request) 29 | 30 | get "/embed/Tweet.html": 31 | let id = @"id" 32 | 33 | if id.len > 0: 34 | redirect(&"/i/status/{id}/embed") 35 | else: 36 | resp Http404 37 | -------------------------------------------------------------------------------- /src/experimental/types/user.nim: -------------------------------------------------------------------------------- 1 | import options 2 | import common 3 | from ../../types import VerifiedType 4 | 5 | type 6 | RawUser* = object 7 | idStr*: string 8 | name*: string 9 | screenName*: string 10 | location*: string 11 | description*: string 12 | entities*: Entities 13 | createdAt*: string 14 | followersCount*: int 15 | friendsCount*: int 16 | favouritesCount*: int 17 | statusesCount*: int 18 | mediaCount*: int 19 | verifiedType*: VerifiedType 20 | protected*: bool 21 | profileLinkColor*: string 22 | profileBannerUrl*: string 23 | profileImageUrlHttps*: string 24 | profileImageExtensions*: Option[ImageExtensions] 25 | pinnedTweetIdsStr*: seq[string] 26 | 27 | Entities* = object 28 | url*: Urls 29 | description*: Urls 30 | 31 | Urls* = object 32 | urls*: seq[Url] 33 | 34 | ImageExtensions = object 35 | mediaColor*: tuple[r: Ok] 36 | 37 | Ok = object 38 | ok*: Palette 39 | 40 | Palette = object 41 | palette*: seq[tuple[rgb: Color]] 42 | 43 | Color* = object 44 | red*, green*, blue*: int 45 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.18 as nim 2 | 3 | RUN apk --no-cache add libsass-dev pcre gcc git libc-dev "nim=1.6.14-r0" "nimble=0.13.1-r2" 4 | 5 | WORKDIR /src/nitter 6 | 7 | COPY nitter.nimble . 8 | RUN nimble install -y --depsOnly 9 | 10 | COPY . . 11 | RUN nimble build -d:danger -d:lto -d:strip \ 12 | && nimble scss \ 13 | && nimble md 14 | 15 | FROM alpine:3.18 as overmind 16 | RUN apk --no-cache add go 17 | RUN go install github.com/DarthSim/overmind/v2@latest 18 | 19 | FROM alpine:3.18 20 | WORKDIR /src/ 21 | RUN apk --no-cache add pcre ca-certificates openssl1.1-compat bash redis tmux nginx python3 py3-pip 22 | COPY --from=nim /src/nitter/nitter ./ 23 | COPY --from=nim /src/nitter/public ./public 24 | 25 | COPY --from=overmind /root/go/bin/overmind ./ 26 | COPY scripts/ ./scripts/ 27 | RUN pip install -r ./scripts/requirements.txt 28 | COPY scripts/assets/redis.conf ./redis.conf 29 | COPY scripts/assets/nginx.conf /etc/nginx/nginx.conf 30 | RUN mkdir -p /etc/nginx/conf.d 31 | 32 | EXPOSE 8081 33 | CMD ["bash", "-c", "/src/scripts/dump_env_and_procfile.sh && rm -f ./.overmind.sock && /src/overmind s"] 34 | -------------------------------------------------------------------------------- /public/fonts/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Font license info 2 | 3 | 4 | ## Entypo 5 | 6 | Copyright (C) 2012 by Daniel Bruce 7 | 8 | Author: Daniel Bruce 9 | License: SIL (http://scripts.sil.org/OFL) 10 | Homepage: http://www.entypo.com 11 | 12 | 13 | ## Iconic 14 | 15 | Copyright (C) 2012 by P.J. Onori 16 | 17 | Author: P.J. Onori 18 | License: SIL (http://scripts.sil.org/OFL) 19 | Homepage: http://somerandomdude.com/work/iconic/ 20 | 21 | 22 | ## Font Awesome 23 | 24 | Copyright (C) 2016 by Dave Gandy 25 | 26 | Author: Dave Gandy 27 | License: SIL () 28 | Homepage: http://fortawesome.github.com/Font-Awesome/ 29 | 30 | 31 | ## Elusive 32 | 33 | Copyright (C) 2013 by Aristeides Stathopoulos 34 | 35 | Author: Aristeides Stathopoulos 36 | License: SIL (http://scripts.sil.org/OFL) 37 | Homepage: http://aristeides.com/ 38 | 39 | 40 | ## Modern Pictograms 41 | 42 | Copyright (c) 2012 by John Caserta. All rights reserved. 43 | 44 | Author: John Caserta 45 | License: SIL (http://scripts.sil.org/OFL) 46 | Homepage: http://thedesignoffice.org/project/modern-pictograms/ 47 | 48 | 49 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/debian 3 | { 4 | "name": "nitter", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/base:bullseye", 7 | "postCreateCommand": ".devcontainer/init.sh", 8 | 9 | // Features to add to the dev container. More info: https://containers.dev/features. 10 | // This doesn't work with Windsurf 11 | // "features": { 12 | // "ghcr.io/devcontainers/features/docker-in-docker:2": {} 13 | // }, 14 | "customizations": { 15 | "vscode": { 16 | "extensions": [ 17 | "TakumiI.markdowntable", 18 | "NimLang.nimlang" 19 | ] 20 | } 21 | }, 22 | 23 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 24 | "forwardPorts": [ 8080 ], 25 | 26 | // Configure tool-specific properties. 27 | // "customizations": {}, 28 | 29 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 30 | // "remoteUser": "root" 31 | } 32 | -------------------------------------------------------------------------------- /src/routes/preferences.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, uri, os, algorithm 3 | 4 | import jester 5 | 6 | import router_utils 7 | import ".."/[types, formatters] 8 | import ../views/[general, preferences] 9 | 10 | export preferences 11 | 12 | proc findThemes*(dir: string): seq[string] = 13 | for kind, path in walkDir(dir / "css" / "themes"): 14 | let theme = path.splitFile.name 15 | result.add theme.replace("_", " ").titleize 16 | sort(result) 17 | 18 | proc createPrefRouter*(cfg: Config) = 19 | router preferences: 20 | get "/settings": 21 | let 22 | prefs = cookiePrefs() 23 | html = renderPreferences(prefs, refPath(), findThemes(cfg.staticDir), cfg.hostname, cfg.useHttps) 24 | resp renderMain(html, request, cfg, prefs, "Preferences") 25 | 26 | get "/settings/@i?": 27 | redirect("/settings") 28 | 29 | post "/saveprefs": 30 | genUpdatePrefs() 31 | redirect(refPath()) 32 | 33 | post "/resetprefs": 34 | genResetPrefs() 35 | redirect("/settings?referer=" & encodeUrl(refPath())) 36 | 37 | post "/enablehls": 38 | savePref("hlsPlayback", "on", request) 39 | redirect(refPath()) 40 | 41 | -------------------------------------------------------------------------------- /src/sass/tweet/video.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | video { 5 | max-height: 100%; 6 | width: 100%; 7 | } 8 | 9 | .gallery-video { 10 | display: flex; 11 | overflow: hidden; 12 | } 13 | 14 | .gallery-video.card-container { 15 | flex-direction: column; 16 | } 17 | 18 | .video-container { 19 | max-height: 530px; 20 | margin: 0; 21 | display: flex; 22 | align-items: center; 23 | justify-content: center; 24 | 25 | img { 26 | max-height: 100%; 27 | max-width: 100%; 28 | } 29 | } 30 | 31 | .video-overlay { 32 | @include play-button; 33 | background-color: $shadow; 34 | 35 | p { 36 | position: relative; 37 | z-index: 0; 38 | text-align: center; 39 | top: calc(50% - 20px); 40 | font-size: 20px; 41 | line-height: 1.3; 42 | margin: 0 20px; 43 | } 44 | 45 | div { 46 | position: relative; 47 | z-index: 0; 48 | top: calc(50% - 20px); 49 | margin: 0 auto; 50 | width: 40px; 51 | height: 40px; 52 | } 53 | 54 | form { 55 | width: 100%; 56 | height: 100%; 57 | align-items: center; 58 | justify-content: center; 59 | display: flex; 60 | } 61 | 62 | button { 63 | padding: 5px 8px; 64 | font-size: 16px; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/http_pool.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import httpclient 3 | 4 | type 5 | HttpPool* = ref object 6 | conns*: seq[AsyncHttpClient] 7 | 8 | var 9 | maxConns: int 10 | proxy: Proxy 11 | 12 | proc setMaxHttpConns*(n: int) = 13 | maxConns = n 14 | 15 | proc setHttpProxy*(url: string; auth: string) = 16 | if url.len > 0: 17 | proxy = newProxy(url, auth) 18 | else: 19 | proxy = nil 20 | 21 | proc release*(pool: HttpPool; client: AsyncHttpClient; badClient=false) = 22 | if pool.conns.len >= maxConns or badClient: 23 | try: client.close() 24 | except: discard 25 | elif client != nil: 26 | pool.conns.insert(client) 27 | 28 | proc acquire*(pool: HttpPool; heads: HttpHeaders): AsyncHttpClient = 29 | if pool.conns.len == 0: 30 | result = newAsyncHttpClient(headers=heads, proxy=proxy) 31 | else: 32 | result = pool.conns.pop() 33 | result.headers = heads 34 | 35 | template use*(pool: HttpPool; heads: HttpHeaders; body: untyped): untyped = 36 | var 37 | c {.inject.} = pool.acquire(heads) 38 | badClient {.inject.} = false 39 | 40 | try: 41 | body 42 | except BadClientError, ProtocolError: 43 | # Twitter returned 503 or closed the connection, we need a new client 44 | pool.release(c, true) 45 | badClient = false 46 | c = pool.acquire(heads) 47 | body 48 | finally: 49 | pool.release(c, badClient) 50 | -------------------------------------------------------------------------------- /src/experimental/parser/graphql.nim: -------------------------------------------------------------------------------- 1 | import options 2 | import jsony 3 | import user, ../types/[graphuser, graphlistmembers] 4 | from ../../types import User, VerifiedType, Result, Query, QueryKind 5 | 6 | proc parseGraphUser*(json: string): User = 7 | if json.len == 0 or json[0] != '{': 8 | return 9 | 10 | let raw = json.fromJson(GraphUser) 11 | 12 | if raw.data.userResult.result.unavailableReason.get("") == "Suspended": 13 | return User(suspended: true) 14 | 15 | result = raw.data.userResult.result.legacy 16 | result.id = raw.data.userResult.result.restId 17 | if result.verifiedType == VerifiedType.none and raw.data.userResult.result.isBlueVerified: 18 | result.verifiedType = blue 19 | 20 | proc parseGraphListMembers*(json, cursor: string): Result[User] = 21 | result = Result[User]( 22 | beginning: cursor.len == 0, 23 | query: Query(kind: userList) 24 | ) 25 | 26 | let raw = json.fromJson(GraphListMembers) 27 | for instruction in raw.data.list.membersTimeline.timeline.instructions: 28 | if instruction.kind == "TimelineAddEntries": 29 | for entry in instruction.entries: 30 | case entry.content.entryType 31 | of TimelineTimelineItem: 32 | let userResult = entry.content.itemContent.userResults.result 33 | if userResult.restId.len > 0: 34 | result.content.add userResult.legacy 35 | of TimelineTimelineCursor: 36 | if entry.content.cursorType == "Bottom": 37 | result.bottom = entry.content.value 38 | -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - "*.md" 7 | branches-ignore: 8 | - master 9 | workflow_call: 10 | 11 | jobs: 12 | test: 13 | runs-on: buildjet-2vcpu-ubuntu-2204 14 | strategy: 15 | matrix: 16 | nim: 17 | - "1.6.10" 18 | - "1.6.x" 19 | - "2.0.x" 20 | - "devel" 21 | steps: 22 | - uses: actions/checkout@v3 23 | with: 24 | fetch-depth: 0 25 | - name: Cache nimble 26 | id: cache-nimble 27 | uses: buildjet/cache@v3 28 | with: 29 | path: ~/.nimble 30 | key: ${{ matrix.nim }}-nimble-${{ hashFiles('*.nimble') }} 31 | restore-keys: | 32 | ${{ matrix.nim }}-nimble- 33 | - uses: actions/setup-python@v4 34 | with: 35 | python-version: "3.10" 36 | cache: "pip" 37 | - uses: jiro4989/setup-nim-action@v1 38 | with: 39 | nim-version: ${{ matrix.nim }} 40 | repo-token: ${{ secrets.GITHUB_TOKEN }} 41 | - run: nimble build -d:release -Y 42 | - run: pip install seleniumbase 43 | - run: seleniumbase install chromedriver 44 | - uses: supercharge/redis-github-action@1.5.0 45 | - name: Prepare Nitter 46 | run: | 47 | sudo apt install libsass-dev -y 48 | cp nitter.example.conf nitter.conf 49 | sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf 50 | nimble md 51 | nimble scss 52 | echo '${{ secrets.GUEST_ACCOUNTS }}' > ./guest_accounts.jsonl 53 | - name: Run tests 54 | run: | 55 | ./nitter & 56 | pytest -n8 tests 57 | -------------------------------------------------------------------------------- /src/sass/profile/_base.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | @import 'card'; 5 | @import 'photo-rail'; 6 | 7 | .profile-tabs { 8 | @include panel(auto, 900px); 9 | 10 | .timeline-container { 11 | float: right; 12 | width: 68% !important; 13 | max-width: unset; 14 | } 15 | } 16 | 17 | .profile-banner { 18 | margin-bottom: 4px; 19 | background-color: var(--bg_panel); 20 | 21 | a { 22 | display: block; 23 | position: relative; 24 | padding: 33.34% 0 0 0; 25 | } 26 | 27 | img { 28 | max-width: 100%; 29 | position: absolute; 30 | top: 0; 31 | } 32 | } 33 | 34 | .profile-tab { 35 | padding: 0 4px 0 0; 36 | box-sizing: border-box; 37 | display: inline-block; 38 | font-size: 14px; 39 | text-align: left; 40 | vertical-align: top; 41 | max-width: 32%; 42 | top: 50px; 43 | } 44 | 45 | .profile-result { 46 | min-height: 54px; 47 | 48 | .username { 49 | margin: 0 !important; 50 | } 51 | 52 | .tweet-header { 53 | margin-bottom: unset; 54 | } 55 | } 56 | 57 | @media(max-width: 700px) { 58 | .profile-tabs { 59 | width: 100vw; 60 | max-width: 600px; 61 | 62 | .timeline-container { 63 | width: 100% !important; 64 | 65 | .tab-item wide { 66 | flex-grow: 1.4; 67 | } 68 | } 69 | } 70 | 71 | .profile-tab { 72 | width: 100%; 73 | max-width: unset; 74 | position: initial !important; 75 | padding: 0; 76 | } 77 | } 78 | 79 | @media (min-height: 900px) { 80 | .profile-tab.sticky { 81 | position: sticky; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | services: 4 | nitter: 5 | build: 6 | context: . 7 | ports: 8 | - "8081:8081" 9 | volumes: 10 | # should map this in PaaS 11 | - nitter-data:/nitter-data 12 | # optional mapping for twitter-credentials.json file 13 | # - ./twitter-credentials.json:/nitter-data/twitter-credentials.json 14 | # - ./nitter.conf:/src/nitter.conf 15 | environment: 16 | # shuold be included for custom paths 17 | - NITTER_ACCOUNTS_FILE=/nitter-data/guest_accounts.json 18 | # optional twitter-credentials.json custom path 19 | # - TWITTER_CREDENTIALS_FILE=/nitter-data/twitter-credentials.json 20 | # optional instance custmizations from env 21 | - INSTANCE_TITLE=Custom title 22 | - INSTANCE_THEME=Twitter Dark 23 | - INSTANCE_INFINITE_SCROLL=1 24 | - INSTANCE_BASE64_MEDIA=1 25 | - INSTANCE_HOSTNAME=localhost:8081 26 | - INSTANCE_RSS_MINUTES=60 27 | # - USE_CUSTOM_CONF=1 28 | # - INSTANCE_HTTPS=1 29 | # optional for setups without redis and/or ng1inx 30 | # - DISABLE_REDIS=1 31 | # - DISABLE_NGINX=1 32 | # optional for setups without redis, e.g. external redis connection info 33 | # - REDIS_HOST=nitter-redis 34 | # - REDIS_PORT=6379 35 | # - REDIS_PASSWORD= 36 | # optional debugging flags 37 | - DEBUG=1 38 | - RESET_NITTER_ACCOUNTS_FILE=1 39 | - INSTANCE_ENABLE_DEBUG=1 40 | env_file: 41 | # should require from env 42 | # TWITTER_USERNAME 43 | # TWITTER_PASSWORD 44 | # INSTANCE_RSS_PASSWORD 45 | # INSTANCE_WEB_USERNAME 46 | # INSTANCE_WEB_PASSWORD 47 | - .env 48 | volumes: 49 | nitter-data: 50 | -------------------------------------------------------------------------------- /src/routes/router_utils.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, sequtils, uri, tables, json 3 | from jester import Request, cookies 4 | 5 | import ../views/general 6 | import ".."/[utils, prefs, types] 7 | export utils, prefs, types, uri 8 | 9 | template savePref*(pref, value: string; req: Request; expire=false) = 10 | if not expire or pref in cookies(req): 11 | setCookie(pref, value, daysForward(when expire: -10 else: 360), 12 | httpOnly=true, secure=cfg.useHttps, sameSite=None) 13 | 14 | template cookiePrefs*(): untyped {.dirty.} = 15 | getPrefs(cookies(request)) 16 | 17 | template cookiePref*(pref): untyped {.dirty.} = 18 | getPref(cookies(request), pref) 19 | 20 | template themePrefs*(): Prefs = 21 | var res = defaultPrefs 22 | res.theme = cookiePref(theme) 23 | res 24 | 25 | template showError*(error: string; cfg: Config): string = 26 | renderMain(renderError(error), request, cfg, themePrefs(), "Error") 27 | 28 | template getPath*(): untyped {.dirty.} = 29 | $(parseUri(request.path) ? filterParams(request.params)) 30 | 31 | template refPath*(): untyped {.dirty.} = 32 | if @"referer".len > 0: @"referer" else: "/" 33 | 34 | template getCursor*(): string = 35 | let cursor = @"cursor" 36 | decodeUrl(if cursor.len > 0: cursor else: @"max_position", false) 37 | 38 | template getCursor*(req: Request): string = 39 | let cursor = req.params.getOrDefault("cursor") 40 | decodeUrl(if cursor.len > 0: cursor 41 | else: req.params.getOrDefault("max_position"), false) 42 | 43 | proc getNames*(name: string): seq[string] = 44 | name.strip(chars={'/'}).split(",").filterIt(it.len > 0) 45 | 46 | template respJson*(node: JsonNode) = 47 | resp $node, "application/json" 48 | -------------------------------------------------------------------------------- /src/utils.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat, uri, tables, base64 3 | import nimcrypto 4 | 5 | var 6 | hmacKey: string 7 | base64Media = false 8 | 9 | const 10 | https* = "https://" 11 | twimg* = "pbs.twimg.com/" 12 | nitterParams = ["name", "tab", "id", "list", "referer", "scroll"] 13 | twitterDomains = @[ 14 | "twitter.com", 15 | "pic.twitter.com", 16 | "twimg.com", 17 | "abs.twimg.com", 18 | "pbs.twimg.com", 19 | "video.twimg.com", 20 | "x.com" 21 | ] 22 | 23 | proc setHmacKey*(key: string) = 24 | hmacKey = key 25 | 26 | proc setProxyEncoding*(state: bool) = 27 | base64Media = state 28 | 29 | proc getHmac*(data: string): string = 30 | ($hmac(sha256, hmacKey, data))[0 .. 12] 31 | 32 | proc getVidUrl*(link: string): string = 33 | if link.len == 0: return 34 | let 35 | link = link.replace("cmaf", "fmp4") 36 | sig = getHmac(link) 37 | if base64Media: 38 | &"/video/enc/{sig}/{encode(link, safe=true)}" 39 | else: 40 | &"/video/{sig}/{encodeUrl(link)}" 41 | 42 | proc getPicUrl*(link: string): string = 43 | if base64Media: 44 | &"/pic/enc/{encode(link, safe=true)}" 45 | else: 46 | &"/pic/{encodeUrl(link)}" 47 | 48 | proc getOrigPicUrl*(link: string): string = 49 | if base64Media: 50 | &"/pic/orig/enc/{encode(link, safe=true)}" 51 | else: 52 | &"/pic/orig/{encodeUrl(link)}" 53 | 54 | proc filterParams*(params: Table): seq[(string, string)] = 55 | for p in params.pairs(): 56 | if p[1].len > 0 and p[0] notin nitterParams: 57 | result.add p 58 | 59 | proc isTwitterUrl*(uri: Uri): bool = 60 | uri.hostname in twitterDomains 61 | 62 | proc isTwitterUrl*(url: string): bool = 63 | isTwitterUrl(parseUri(url)) 64 | -------------------------------------------------------------------------------- /src/sass/navbar.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | nav { 4 | display: flex; 5 | align-items: center; 6 | position: fixed; 7 | background-color: var(--bg_overlays); 8 | box-shadow: 0 0 4px $shadow; 9 | padding: 0; 10 | width: 100%; 11 | height: 50px; 12 | z-index: 1000; 13 | font-size: 16px; 14 | 15 | a, .icon-button button { 16 | color: var(--fg_nav); 17 | } 18 | } 19 | 20 | .inner-nav { 21 | margin: auto; 22 | box-sizing: border-box; 23 | padding: 0 10px; 24 | display: flex; 25 | align-items: center; 26 | flex-basis: 920px; 27 | height: 50px; 28 | } 29 | 30 | .site-name { 31 | font-size: 15px; 32 | font-weight: 600; 33 | line-height: 1; 34 | 35 | &:hover { 36 | color: var(--accent_light); 37 | text-decoration: unset; 38 | } 39 | } 40 | 41 | .site-logo { 42 | display: block; 43 | width: 35px; 44 | height: 35px; 45 | } 46 | 47 | .nav-item { 48 | display: flex; 49 | flex: 1; 50 | line-height: 50px; 51 | height: 50px; 52 | overflow: hidden; 53 | flex-wrap: wrap; 54 | align-items: center; 55 | 56 | &.right { 57 | text-align: right; 58 | justify-content: flex-end; 59 | } 60 | 61 | &.right a { 62 | padding-left: 4px; 63 | 64 | &:hover { 65 | color: var(--accent_light); 66 | text-decoration: unset; 67 | } 68 | } 69 | } 70 | 71 | .lp { 72 | height: 14px; 73 | display: inline-block; 74 | position: relative; 75 | top: 2px; 76 | fill: var(--fg_nav); 77 | 78 | &:hover { 79 | fill: var(--accent_light); 80 | } 81 | } 82 | 83 | .icon-info:before { 84 | margin: 0 -3px; 85 | } 86 | 87 | .icon-cog { 88 | font-size: 15px; 89 | } 90 | -------------------------------------------------------------------------------- /src/routes/search.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, uri 3 | 4 | import jester 5 | 6 | import router_utils 7 | import ".."/[query, types, api, formatters] 8 | import ../views/[general, search] 9 | 10 | include "../views/opensearch.nimf" 11 | 12 | export search 13 | 14 | proc createSearchRouter*(cfg: Config) = 15 | router search: 16 | get "/search/?": 17 | let q = @"q" 18 | if q.len > 500: 19 | resp Http400, showError("Search input too long.", cfg) 20 | 21 | let 22 | prefs = cookiePrefs() 23 | query = initQuery(params(request)) 24 | title = "Search" & (if q.len > 0: " (" & q & ")" else: "") 25 | 26 | case query.kind 27 | of users: 28 | if "," in q: 29 | redirect("/" & q) 30 | var users: Result[User] 31 | try: 32 | users = await getGraphUserSearch(query, getCursor()) 33 | except InternalError: 34 | users = Result[User](beginning: true, query: query) 35 | resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title) 36 | of tweets: 37 | let 38 | tweets = await getGraphTweetSearch(query, getCursor()) 39 | rss = "/search/rss?" & genQueryUrl(query) 40 | resp renderMain(renderTweetSearch(tweets, prefs, getPath()), 41 | request, cfg, prefs, title, rss=rss) 42 | else: 43 | resp Http404, showError("Invalid search", cfg) 44 | 45 | get "/hashtag/@hash": 46 | redirect("/search?q=" & encodeUrl("#" & @"hash")) 47 | 48 | get "/opensearch": 49 | let url = getUrlPrefix(cfg) & "/search?q=" 50 | resp Http200, {"Content-Type": "application/opensearchdescription+xml"}, 51 | generateOpenSearchXML(cfg.title, cfg.hostname, url) 52 | -------------------------------------------------------------------------------- /src/config.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import parsecfg except Config 3 | import types, strutils 4 | 5 | proc get*[T](config: parseCfg.Config; section, key: string; default: T): T = 6 | let val = config.getSectionValue(section, key) 7 | if val.len == 0: return default 8 | 9 | when T is int: parseInt(val) 10 | elif T is bool: parseBool(val) 11 | elif T is string: val 12 | 13 | proc getConfig*(path: string): (Config, parseCfg.Config) = 14 | var cfg = loadConfig(path) 15 | 16 | let conf = Config( 17 | # Server 18 | address: cfg.get("Server", "address", "0.0.0.0"), 19 | port: cfg.get("Server", "port", 8080), 20 | useHttps: cfg.get("Server", "https", true), 21 | httpMaxConns: cfg.get("Server", "httpMaxConnections", 100), 22 | staticDir: cfg.get("Server", "staticDir", "./public"), 23 | title: cfg.get("Server", "title", "Nitter"), 24 | hostname: cfg.get("Server", "hostname", "nitter.net"), 25 | 26 | # Cache 27 | listCacheTime: cfg.get("Cache", "listMinutes", 120), 28 | rssCacheTime: cfg.get("Cache", "rssMinutes", 10), 29 | 30 | redisHost: cfg.get("Cache", "redisHost", "localhost"), 31 | redisPort: cfg.get("Cache", "redisPort", 6379), 32 | redisConns: cfg.get("Cache", "redisConnections", 20), 33 | redisMaxConns: cfg.get("Cache", "redisMaxConnections", 30), 34 | redisPassword: cfg.get("Cache", "redisPassword", ""), 35 | 36 | # Config 37 | hmacKey: cfg.get("Config", "hmacKey", "secretkey"), 38 | base64Media: cfg.get("Config", "base64Media", false), 39 | minTokens: cfg.get("Config", "tokenCount", 10), 40 | enableRss: cfg.get("Config", "enableRSS", true), 41 | enableDebug: cfg.get("Config", "enableDebug", false), 42 | proxy: cfg.get("Config", "proxy", ""), 43 | proxyAuth: cfg.get("Config", "proxyAuth", "") 44 | ) 45 | 46 | return (conf, cfg) 47 | -------------------------------------------------------------------------------- /src/routes/list.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat, uri 3 | 4 | import jester 5 | 6 | import router_utils 7 | import ".."/[types, redis_cache, api] 8 | import ../views/[general, timeline, list] 9 | 10 | template respList*(list, timeline, title, vnode: typed) = 11 | if list.id.len == 0 or list.name.len == 0: 12 | resp Http404, showError(&"""List "{@"id"}" not found""", cfg) 13 | 14 | let 15 | html = renderList(vnode, timeline.query, list) 16 | rss = &"""/i/lists/{@"id"}/rss""" 17 | 18 | resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner) 19 | 20 | proc title*(list: List): string = 21 | &"@{list.username}/{list.name}" 22 | 23 | proc createListRouter*(cfg: Config) = 24 | router list: 25 | get "/@name/lists/@slug/?": 26 | cond '.' notin @"name" 27 | cond @"name" != "i" 28 | cond @"slug" != "memberships" 29 | let 30 | slug = decodeUrl(@"slug") 31 | list = await getCachedList(@"name", slug) 32 | if list.id.len == 0: 33 | resp Http404, showError(&"""List "{@"slug"}" not found""", cfg) 34 | redirect(&"/i/lists/{list.id}") 35 | 36 | get "/i/lists/@id/?": 37 | cond '.' notin @"id" 38 | let 39 | prefs = cookiePrefs() 40 | list = await getCachedList(id=(@"id")) 41 | timeline = await getGraphListTweets(list.id, getCursor()) 42 | vnode = renderTimelineTweets(timeline, prefs, request.path) 43 | respList(list, timeline, list.title, vnode) 44 | 45 | get "/i/lists/@id/members": 46 | cond '.' notin @"id" 47 | let 48 | prefs = cookiePrefs() 49 | list = await getCachedList(id=(@"id")) 50 | members = await getGraphListMembers(list, getCursor()) 51 | respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path)) 52 | -------------------------------------------------------------------------------- /scripts/dump_env_and_procfile.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo Dumping auth env... 5 | echo TWITTER_USERNAME=$TWITTER_USERNAME > /src/.env 6 | echo TWITTER_PASSWORD=$TWITTER_PASSWORD >> /src/.env 7 | echo TWITTER_CREDENTIALS_FILE=$TWITTER_CREDENTIALS_FILE >> /src/.env 8 | echo RESET_NITTER_ACCOUNTS_FILE=$RESET_NITTER_ACCOUNTS_FILE >> /src/.env 9 | echo DEBUG=$DEBUG >> /src/.env 10 | 11 | echo Dumping custom path env... 12 | echo NITTER_ACCOUNTS_FILE=$NITTER_ACCOUNTS_FILE >> /src/.env 13 | 14 | echo Dumping redis connection env... 15 | echo REDIS_HOST=$REDIS_HOST >> /src/.env 16 | echo REDIS_PORT=$REDIS_PORT >> /src/.env 17 | echo REDIS_PASSWORD=$REDIS_PASSWORD >> /src/.env 18 | 19 | echo Dumping instance customization env... 20 | echo INSTANCE_PORT=$INSTANCE_PORT >> /src/.env 21 | echo INSTANCE_HTTPS=$INSTANCE_HTTPS >> /src/.env 22 | echo FLY_APP_NAME=$FLY_APP_NAME >> /src/.env 23 | echo INSTANCE_HOSTNAME=$INSTANCE_HOSTNAME >> /src/.env 24 | echo INSTANCE_BASE64_MEDIA=$INSTANCE_BASE64_MEDIA >> /src/.env 25 | echo INSTANCE_TITLE=$INSTANCE_TITLE >> /src/.env 26 | echo INSTANCE_THEME=$INSTANCE_THEME >> /src/.env 27 | echo INSTANCE_INFINITE_SCROLL=$INSTANCE_INFINITE_SCROLL >> /src/.env 28 | echo INSTANCE_ENABLE_DEBUG=$INSTANCE_ENABLE_DEBUG >> /src/.env 29 | echo INSTANCE_RSS_MINUTES=$INSTANCE_RSS_MINUTES >> /src/.env 30 | echo USE_CUSTOM_CONF=$USE_CUSTOM_CONF >> /src/.env 31 | 32 | echo Dumping nginx env... 33 | echo INSTANCE_RSS_PASSWORD=$INSTANCE_RSS_PASSWORD >> /src/.env 34 | echo INSTANCE_WEB_USERNAME=$INSTANCE_WEB_USERNAME >> /src/.env 35 | echo INSTANCE_WEB_PASSWORD=$INSTANCE_WEB_PASSWORD >> /src/.env 36 | 37 | echo Writing Procfile... 38 | 39 | echo "web: /src/scripts/nitter.sh" > /src/Procfile 40 | 41 | if [ "$DISABLE_REDIS" != "1" ]; then 42 | echo "redis: /src/scripts/redis.sh" >> /src/Procfile 43 | fi 44 | echo DISABLE_REDIS=$DISABLE_REDIS >> /src/.env 45 | 46 | if [ "$DISABLE_NGINX" != "1" ]; then 47 | echo "nginx: /src/scripts/nginx.sh" >> /src/Procfile 48 | fi 49 | -------------------------------------------------------------------------------- /nitter.example.conf: -------------------------------------------------------------------------------- 1 | [Server] 2 | hostname = "nitter.net" # for generating links, change this to your own domain/ip 3 | title = "nitter" 4 | address = "0.0.0.0" 5 | port = 8080 6 | https = false # disable to enable cookies when not using https 7 | httpMaxConnections = 100 8 | staticDir = "./public" 9 | 10 | [Cache] 11 | listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) 12 | rssMinutes = 10 # how long to cache rss queries 13 | redisHost = "localhost" # Change to "nitter-redis" if using docker-compose 14 | redisPort = 6379 15 | redisPassword = "" 16 | redisConnections = 20 # minimum open connections in pool 17 | redisMaxConnections = 30 18 | # new connections are opened when none are available, but if the pool size 19 | # goes above this, they're closed when released. don't worry about this unless 20 | # you receive tons of requests per second 21 | 22 | [Config] 23 | hmacKey = "secretkey" # random key for cryptographic signing of video urls 24 | base64Media = false # use base64 encoding for proxied media urls 25 | enableRSS = true # set this to false to disable RSS feeds 26 | enableDebug = false # enable request logs and debug endpoints (/.accounts) 27 | proxy = "" # http/https url, SOCKS proxies are not supported 28 | proxyAuth = "" 29 | tokenCount = 10 30 | # minimum amount of usable tokens. tokens are used to authorize API requests, 31 | # but they expire after ~1 hour, and have a limit of 500 requests per endpoint. 32 | # the limits reset every 15 minutes, and the pool is filled up so there's 33 | # always at least `tokenCount` usable tokens. only increase this if you receive 34 | # major bursts all the time and don't have a rate limiting setup via e.g. nginx 35 | 36 | # Change default preferences here, see src/prefs_impl.nim for a complete list 37 | [Preferences] 38 | theme = "Nitter" 39 | replaceTwitter = "nitter.net" 40 | replaceYouTube = "piped.video" 41 | replaceReddit = "teddit.net" 42 | proxyVideos = true 43 | hlsPlayback = false 44 | infiniteScroll = false 45 | -------------------------------------------------------------------------------- /src/sass/tweet/quote.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | .quote { 4 | margin-top: 10px; 5 | border: solid 1px var(--dark_grey); 6 | border-radius: 10px; 7 | background-color: var(--bg_elements); 8 | overflow: hidden; 9 | pointer-events: all; 10 | position: relative; 11 | width: 100%; 12 | 13 | &:hover { 14 | border-color: var(--grey); 15 | } 16 | 17 | &.unavailable:hover { 18 | border-color: var(--dark_grey); 19 | } 20 | 21 | .tweet-name-row { 22 | padding: 6px 8px; 23 | margin-top: 1px; 24 | } 25 | 26 | .quote-text { 27 | overflow: hidden; 28 | white-space: pre-wrap; 29 | word-wrap: break-word; 30 | padding: 0px 8px 8px 8px; 31 | } 32 | 33 | .show-thread { 34 | padding: 0px 8px 6px 8px; 35 | margin-top: -6px; 36 | } 37 | 38 | .replying-to { 39 | padding: 0px 8px; 40 | margin: unset; 41 | } 42 | } 43 | 44 | .unavailable-quote { 45 | padding: 12px; 46 | } 47 | 48 | .quote-link { 49 | width: 100%; 50 | height: 100%; 51 | left: 0; 52 | top: 0; 53 | position: absolute; 54 | } 55 | 56 | .quote-media-container { 57 | max-height: 300px; 58 | display: flex; 59 | 60 | .card { 61 | margin: unset; 62 | } 63 | 64 | .attachments { 65 | border-radius: 0; 66 | } 67 | 68 | .media-gif { 69 | width: 100%; 70 | display: flex; 71 | justify-content: center; 72 | } 73 | 74 | .gallery-gif .attachment { 75 | display: flex; 76 | justify-content: center; 77 | background-color: var(--bg_color); 78 | 79 | video { 80 | height: unset; 81 | width: unset; 82 | max-height: 100%; 83 | max-width: 100%; 84 | } 85 | } 86 | 87 | .gallery-video, .gallery-gif { 88 | max-height: 300px; 89 | } 90 | 91 | .still-image img { 92 | max-height: 250px 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/test_thread.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Conversation 2 | from parameterized import parameterized 3 | 4 | thread = [ 5 | ['octonion/status/975253897697611777', [], 'Based', ['Crystal', 'Julia'], [ 6 | ['For', 'Then', 'Okay,', 'Python', 'Speed', 'Java', 'Coding', 'I', 'You'], 7 | ['yeah,'] 8 | ]], 9 | 10 | ['octonion/status/975254452625002496', ['Based'], 'Crystal', ['Julia'], []], 11 | 12 | ['octonion/status/975256058384887808', ['Based', 'Crystal'], 'Julia', [], []], 13 | 14 | ['gauravssnl/status/975364889039417344', 15 | ['Based', 'For', 'Then', 'Okay,', 'Python'], 'Speed', [], [ 16 | ['Java', 'Coding', 'I', 'You'], ['JAVA!'] 17 | ]], 18 | 19 | ['d0m96/status/1141811379407425537', [], 'I\'m', 20 | ['The', 'The', 'Today', 'Some', 'If', 'There', 'Above'], 21 | [['Thank', 'Also,']]], 22 | 23 | ['gmpreussner/status/999766552546299904', [], 'A', [], 24 | [['I', 'Especially'], ['I']]] 25 | ] 26 | 27 | 28 | class ThreadTest(BaseTestCase): 29 | def find_tweets(self, selector): 30 | return self.find_elements(f"{selector} {Conversation.tweet_text}") 31 | 32 | def compare_first_word(self, tweets, selector): 33 | if len(tweets) > 0: 34 | self.assert_element_visible(selector) 35 | for i, tweet in enumerate(self.find_tweets(selector)): 36 | text = tweet.text.split(" ")[0] 37 | self.assert_equal(tweets[i], text) 38 | 39 | @parameterized.expand(thread) 40 | def test_thread(self, tweet, before, main, after, replies): 41 | self.open_nitter(tweet) 42 | self.assert_element_visible(Conversation.main) 43 | 44 | self.assert_text(main, Conversation.main) 45 | self.assert_text(main, Conversation.main) 46 | 47 | self.compare_first_word(before, Conversation.before) 48 | self.compare_first_word(after, Conversation.after) 49 | 50 | for i, reply in enumerate(self.find_elements(Conversation.thread)): 51 | selector = Conversation.replies + f" > div:nth-child({i + 1})" 52 | self.compare_first_word(replies[i], selector) 53 | -------------------------------------------------------------------------------- /public/css/fontello.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'fontello'; 3 | src: url('/fonts/fontello.eot?21002321'); 4 | src: url('/fonts/fontello.eot?21002321#iefix') format('embedded-opentype'), 5 | url('/fonts/fontello.woff2?21002321') format('woff2'), 6 | url('/fonts/fontello.woff?21002321') format('woff'), 7 | url('/fonts/fontello.ttf?21002321') format('truetype'), 8 | url('/fonts/fontello.svg?21002321#fontello') format('svg'); 9 | font-weight: normal; 10 | font-style: normal; 11 | } 12 | 13 | [class^="icon-"]:before, [class*=" icon-"]:before { 14 | font-family: "fontello"; 15 | font-style: normal; 16 | font-weight: normal; 17 | speak: never; 18 | 19 | display: inline-block; 20 | text-decoration: inherit; 21 | width: 1em; 22 | text-align: center; 23 | 24 | /* For safety - reset parent styles, that can break glyph codes*/ 25 | font-variant: normal; 26 | text-transform: none; 27 | 28 | /* fix buttons height, for twitter bootstrap */ 29 | line-height: 1em; 30 | 31 | /* Font smoothing. That was taken from TWBS */ 32 | -webkit-font-smoothing: antialiased; 33 | -moz-osx-font-smoothing: grayscale; 34 | } 35 | 36 | .icon-heart:before { content: '\2665'; } /* '♥' */ 37 | .icon-quote:before { content: '\275e'; } /* '❞' */ 38 | .icon-comment:before { content: '\e802'; } /* '' */ 39 | .icon-ok:before { content: '\e803'; } /* '' */ 40 | .icon-play:before { content: '\e804'; } /* '' */ 41 | .icon-link:before { content: '\e805'; } /* '' */ 42 | .icon-calendar:before { content: '\e806'; } /* '' */ 43 | .icon-location:before { content: '\e807'; } /* '' */ 44 | .icon-picture:before { content: '\e809'; } /* '' */ 45 | .icon-lock:before { content: '\e80a'; } /* '' */ 46 | .icon-down:before { content: '\e80b'; } /* '' */ 47 | .icon-retweet:before { content: '\e80d'; } /* '' */ 48 | .icon-search:before { content: '\e80e'; } /* '' */ 49 | .icon-pin:before { content: '\e80f'; } /* '' */ 50 | .icon-cog:before { content: '\e812'; } /* '' */ 51 | .icon-rss-feed:before { content: '\e813'; } /* '' */ 52 | .icon-info:before { content: '\f128'; } /* '' */ 53 | .icon-bird:before { content: '\f309'; } /* '' */ 54 | -------------------------------------------------------------------------------- /src/experimental/parser/slices.nim: -------------------------------------------------------------------------------- 1 | import std/[macros, htmlgen, unicode] 2 | import ../types/common 3 | import ".."/../[formatters, utils] 4 | 5 | type 6 | ReplaceSliceKind = enum 7 | rkRemove, rkUrl, rkHashtag, rkMention 8 | 9 | ReplaceSlice* = object 10 | slice: Slice[int] 11 | kind: ReplaceSliceKind 12 | url, display: string 13 | 14 | proc cmp*(x, y: ReplaceSlice): int = cmp(x.slice.a, y.slice.b) 15 | 16 | proc dedupSlices*(s: var seq[ReplaceSlice]) = 17 | var 18 | len = s.len 19 | i = 0 20 | while i < len: 21 | var j = i + 1 22 | while j < len: 23 | if s[i].slice.a == s[j].slice.a: 24 | s.del j 25 | dec len 26 | else: 27 | inc j 28 | inc i 29 | 30 | proc extractUrls*(result: var seq[ReplaceSlice]; url: Url; 31 | textLen: int; hideTwitter = false) = 32 | let 33 | link = url.expandedUrl 34 | slice = url.indices[0] ..< url.indices[1] 35 | 36 | if hideTwitter and slice.b.succ >= textLen and link.isTwitterUrl: 37 | if slice.a < textLen: 38 | result.add ReplaceSlice(kind: rkRemove, slice: slice) 39 | else: 40 | result.add ReplaceSlice(kind: rkUrl, url: link, 41 | display: link.shortLink, slice: slice) 42 | 43 | proc replacedWith*(runes: seq[Rune]; repls: openArray[ReplaceSlice]; 44 | textSlice: Slice[int]): string = 45 | template extractLowerBound(i: int; idx): int = 46 | if i > 0: repls[idx].slice.b.succ else: textSlice.a 47 | 48 | result = newStringOfCap(runes.len) 49 | 50 | for i, rep in repls: 51 | result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a] 52 | case rep.kind 53 | of rkHashtag: 54 | let 55 | name = $runes[rep.slice.a.succ .. rep.slice.b] 56 | symbol = $runes[rep.slice.a] 57 | result.add a(symbol & name, href = "/search?q=%23" & name) 58 | of rkMention: 59 | result.add a($runes[rep.slice], href = rep.url, title = rep.display) 60 | of rkUrl: 61 | result.add a(rep.display, href = rep.url) 62 | of rkRemove: 63 | discard 64 | 65 | let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b 66 | if rest.a <= rest.b: 67 | result.add $runes[rest] 68 | -------------------------------------------------------------------------------- /tests/test_quote.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Quote, Conversation 2 | from parameterized import parameterized 3 | 4 | text = [ 5 | ['elonmusk/status/1138136540096319488', 6 | 'TREV PAGE', '@Model3Owners', 7 | """As of March 58.4% of new car sales in Norway are electric. 8 | 9 | What are we doing wrong? reuters.com/article/us-norwa…"""], 10 | 11 | ['nim_lang/status/1491461266849808397#m', 12 | 'Nim', '@nim_lang', 13 | """What's better than Nim 1.6.0? 14 | 15 | Nim 1.6.2 :) 16 | 17 | nim-lang.org/blog/2021/12/17…"""] 18 | ] 19 | 20 | image = [ 21 | ['elonmusk/status/1138827760107790336', 'D83h6Y8UIAE2Wlz'], 22 | ['SpaceX/status/1067155053461426176', 'Ds9EYfxXoAAPNmx'] 23 | ] 24 | 25 | gif = [ 26 | ['SpaceX/status/747497521593737216', 'Cl-R5yFWkAA_-3X'], 27 | ['nim_lang/status/1068099315074248704', 'DtJSqP9WoAAKdRC'] 28 | ] 29 | 30 | video = [ 31 | ['bkuensting/status/1067316003200217088', 'IyCaQlzF0q8u9vBd'] 32 | ] 33 | 34 | 35 | class QuoteTest(BaseTestCase): 36 | @parameterized.expand(text) 37 | def test_text(self, tweet, fullname, username, text): 38 | self.open_nitter(tweet) 39 | quote = Quote(Conversation.main + " ") 40 | self.assert_text(fullname, quote.fullname) 41 | self.assert_text(username, quote.username) 42 | self.assert_text(text, quote.text) 43 | 44 | @parameterized.expand(image) 45 | def test_image(self, tweet, url): 46 | self.open_nitter(tweet) 47 | quote = Quote(Conversation.main + " ") 48 | self.assert_element_visible(quote.media) 49 | self.assertIn(url, self.get_image_url(quote.media + ' img')) 50 | 51 | @parameterized.expand(gif) 52 | def test_gif(self, tweet, url): 53 | self.open_nitter(tweet) 54 | quote = Quote(Conversation.main + " ") 55 | self.assert_element_visible(quote.media) 56 | self.assertIn(url, self.get_attribute(quote.media + ' source', 'src')) 57 | 58 | @parameterized.expand(video) 59 | def test_video(self, tweet, url): 60 | self.open_nitter(tweet) 61 | quote = Quote(Conversation.main + " ") 62 | self.assert_element_visible(quote.media) 63 | self.assertIn(url, self.get_image_url(quote.media + ' img')) 64 | -------------------------------------------------------------------------------- /src/sass/profile/photo-rail.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | .photo-rail { 4 | &-card { 5 | float: left; 6 | background: var(--bg_panel); 7 | border-radius: 0 0 4px 4px; 8 | width: 100%; 9 | margin: 5px 0; 10 | } 11 | 12 | &-header { 13 | padding: 5px 12px 0; 14 | } 15 | 16 | &-header-mobile { 17 | display: none; 18 | box-sizing: border-box; 19 | padding: 5px 12px 0; 20 | width: 100%; 21 | float: unset; 22 | color: var(--accent); 23 | justify-content: space-between; 24 | } 25 | 26 | &-grid { 27 | display: grid; 28 | grid-template-columns: repeat(4, 1fr); 29 | grid-gap: 3px 3px; 30 | padding: 5px 12px 12px; 31 | 32 | a { 33 | position: relative; 34 | border-radius: 5px; 35 | background-color: var(--darker_grey); 36 | 37 | &:before { 38 | content: ""; 39 | display: block; 40 | padding-top: 100%; 41 | } 42 | } 43 | 44 | img { 45 | height: 100%; 46 | width: 100%; 47 | border-radius: 4px; 48 | object-fit: cover; 49 | position: absolute; 50 | top: 0; 51 | left: 0; 52 | bottom: 0; 53 | right: 0; 54 | } 55 | } 56 | } 57 | 58 | @include create-toggle(photo-rail-grid, 640px); 59 | #photo-rail-grid-toggle:checked ~ .photo-rail-grid { 60 | padding-bottom: 12px; 61 | } 62 | 63 | @media(max-width: 700px) { 64 | .photo-rail-header { 65 | display: none; 66 | } 67 | 68 | .photo-rail-header-mobile { 69 | display: flex; 70 | } 71 | 72 | .photo-rail-grid { 73 | max-height: 0; 74 | padding-bottom: 0; 75 | overflow: hidden; 76 | transition: max-height 0.4s; 77 | } 78 | 79 | .photo-rail-grid { 80 | grid-template-columns: repeat(6, 1fr); 81 | } 82 | 83 | #photo-rail-grid-toggle:checked ~ .photo-rail-grid { 84 | max-height: 300px; 85 | } 86 | } 87 | 88 | @media(max-width: 450px) { 89 | .photo-rail-grid { 90 | grid-template-columns: repeat(4, 1fr); 91 | } 92 | 93 | #photo-rail-grid-toggle:checked ~ .photo-rail-grid { 94 | max-height: 450px; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/sass/include/_mixins.css: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | @mixin panel($width, $max-width) { 4 | max-width: $max-width; 5 | margin: 0 auto; 6 | float: none; 7 | border-radius: 0; 8 | position: relative; 9 | width: $width; 10 | } 11 | 12 | @mixin play-button { 13 | position: absolute; 14 | width: 100%; 15 | height: 100%; 16 | top: 0; 17 | left: 0; 18 | z-index: 1; 19 | 20 | &:hover { 21 | .overlay-circle { 22 | border-color: var(--play_button_hover); 23 | } 24 | 25 | .overlay-triangle { 26 | border-color: transparent transparent transparent var(--play_button_hover); 27 | } 28 | } 29 | } 30 | 31 | @mixin breakable { 32 | overflow: hidden; 33 | overflow-wrap: break-word; 34 | } 35 | 36 | @mixin ellipsis { 37 | overflow: hidden; 38 | text-overflow: ellipsis; 39 | white-space: nowrap; 40 | } 41 | 42 | @mixin center-panel($bg) { 43 | padding: 12px; 44 | border-radius: 4px; 45 | display: flex; 46 | background: $bg; 47 | box-shadow: 0 0 15px $shadow_dark; 48 | margin: auto; 49 | margin-top: -50px; 50 | } 51 | 52 | @mixin input-colors { 53 | &:hover { 54 | border-color: var(--accent); 55 | } 56 | 57 | &:active { 58 | border-color: var(--accent_light); 59 | } 60 | } 61 | 62 | @mixin search-resize($width, $rows) { 63 | @media(max-width: $width) { 64 | .search-toggles { 65 | grid-template-columns: repeat($rows, auto); 66 | } 67 | 68 | #search-panel-toggle:checked ~ .search-panel { 69 | @if $rows == 6 { 70 | max-height: 200px !important; 71 | } 72 | @if $rows == 5 { 73 | max-height: 300px !important; 74 | } 75 | @if $rows == 4 { 76 | max-height: 300px !important; 77 | } 78 | @if $rows == 3 { 79 | max-height: 365px !important; 80 | } 81 | } 82 | } 83 | } 84 | 85 | @mixin create-toggle($elem, $height) { 86 | ##{$elem}-toggle { 87 | display: none; 88 | 89 | &:checked ~ .#{$elem} { 90 | max-height: $height; 91 | } 92 | 93 | &:checked ~ label .icon-down:before { 94 | transform: rotate(180deg) translateY(-1px); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/sass/tweet/card.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | .card { 5 | margin: 5px 0; 6 | pointer-events: all; 7 | max-height: unset; 8 | } 9 | 10 | .card-container { 11 | border-radius: 10px; 12 | border-width: 1px; 13 | border-style: solid; 14 | border-color: var(--dark_grey); 15 | background-color: var(--bg_elements); 16 | overflow: hidden; 17 | color: inherit; 18 | display: flex; 19 | flex-direction: row; 20 | text-decoration: none !important; 21 | 22 | &:hover { 23 | border-color: var(--grey); 24 | } 25 | 26 | .attachments { 27 | margin: 0; 28 | border-radius: 0; 29 | } 30 | } 31 | 32 | .card-content { 33 | padding: 0.5em; 34 | } 35 | 36 | .card-title { 37 | @include ellipsis; 38 | white-space: unset; 39 | font-weight: bold; 40 | font-size: 1.1em; 41 | } 42 | 43 | .card-description { 44 | margin: 0.3em 0; 45 | } 46 | 47 | .card-destination { 48 | @include ellipsis; 49 | color: var(--grey); 50 | display: block; 51 | } 52 | 53 | .card-content-container { 54 | color: unset; 55 | overflow: auto; 56 | &:hover { 57 | text-decoration: none; 58 | } 59 | } 60 | 61 | .card-image-container { 62 | width: 98px; 63 | flex-shrink: 0; 64 | position: relative; 65 | overflow: hidden; 66 | &:before { 67 | content: ""; 68 | display: block; 69 | padding-top: 100%; 70 | } 71 | } 72 | 73 | .card-image { 74 | position: absolute; 75 | top: 0; 76 | left: 0; 77 | bottom: 0; 78 | right: 0; 79 | background-color: var(--bg_overlays); 80 | 81 | img { 82 | width: 100%; 83 | height: 100%; 84 | max-height: 400px; 85 | display: block; 86 | object-fit: cover; 87 | } 88 | } 89 | 90 | .card-overlay { 91 | @include play-button; 92 | opacity: 0.8; 93 | display: flex; 94 | justify-content: center; 95 | align-items: center; 96 | } 97 | 98 | .large { 99 | .card-container { 100 | display: block; 101 | } 102 | 103 | .card-image-container { 104 | width: unset; 105 | 106 | &:before { 107 | display: none; 108 | } 109 | } 110 | 111 | .card-image { 112 | position: unset; 113 | border-style: solid; 114 | border-color: var(--dark_grey); 115 | border-width: 0; 116 | border-bottom-width: 1px; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /public/md/about.md: -------------------------------------------------------------------------------- 1 | # About 2 | 3 | Nitter is a free and open source alternative Twitter front-end focused on 4 | privacy and performance. The source is available on GitHub at 5 | 6 | 7 | * No JavaScript or ads 8 | * All requests go through the backend, client never talks to Twitter 9 | * Prevents Twitter from tracking your IP or JavaScript fingerprint 10 | * Uses Twitter's unofficial API (no rate limits or developer account required) 11 | * Lightweight (for [@nim_lang](/nim_lang), 60KB vs 784KB from twitter.com) 12 | * RSS feeds 13 | * Themes 14 | * Mobile support (responsive design) 15 | * AGPLv3 licensed, no proprietary instances permitted 16 | 17 | Nitter's GitHub wiki contains 18 | [instances](https://github.com/zedeus/nitter/wiki/Instances) and 19 | [browser extensions](https://github.com/zedeus/nitter/wiki/Extensions) 20 | maintained by the community. 21 | 22 | ## Why use Nitter? 23 | 24 | It's impossible to use Twitter without JavaScript enabled. For privacy-minded 25 | folks, preventing JavaScript analytics and IP-based tracking is important, but 26 | apart from using a VPN and uBlock/uMatrix, it's impossible. Despite being behind 27 | a VPN and using heavy-duty adblockers, you can get accurately tracked with your 28 | [browser's fingerprint](https://restoreprivacy.com/browser-fingerprinting/), 29 | [no JavaScript required](https://noscriptfingerprint.com/). This all became 30 | particularly important after Twitter [removed the 31 | ability](https://www.eff.org/deeplinks/2020/04/twitter-removes-privacy-option-and-shows-why-we-need-strong-privacy-laws) 32 | for users to control whether their data gets sent to advertisers. 33 | 34 | Using an instance of Nitter (hosted on a VPS for example), you can browse 35 | Twitter without JavaScript while retaining your privacy. In addition to 36 | respecting your privacy, Nitter is on average around 15 times lighter than 37 | Twitter, and in most cases serves pages faster (eg. timelines load 2-4x faster). 38 | 39 | In the future a simple account system will be added that lets you follow Twitter 40 | users, allowing you to have a clean chronological timeline without needing a 41 | Twitter account. 42 | 43 | ## Donating 44 | 45 | Liberapay: \ 46 | Patreon: \ 47 | BTC: bc1qp7q4qz0fgfvftm5hwz3vy284nue6jedt44kxya \ 48 | ETH: 0x66d84bc3fd031b62857ad18c62f1ba072b011925 \ 49 | LTC: ltc1qhsz5nxw6jw9rdtw9qssjeq2h8hqk2f85rdgpkr \ 50 | XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscWGWJCczFLe9RFhM3d1zpL 51 | 52 | ## Contact 53 | 54 | Feel free to join our [Matrix channel](https://matrix.to/#/#nitter:matrix.org). 55 | -------------------------------------------------------------------------------- /tests/test_timeline.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Timeline 2 | from parameterized import parameterized 3 | 4 | normal = [['jack'], ['elonmusk']] 5 | 6 | after = [['jack', '1681686036294803456'], 7 | ['elonmusk', '1681686036294803456']] 8 | 9 | no_more = [['mobile_test_8?cursor=DAABCgABF4YVAqN___kKAAICNn_4msIQAAgAAwAAAAIAAA']] 10 | 11 | empty = [['emptyuser'], ['mobile_test_10']] 12 | 13 | protected = [['mobile_test_7'], ['Empty_user']] 14 | 15 | photo_rail = [['mobile_test', ['Bo0nDsYIYAIjqVn', 'BoQbwJAIUAA0QCY', 'BoQbRQxIIAA3FWD', 'Bn8Qh8iIIAABXrG']]] 16 | 17 | 18 | class TweetTest(BaseTestCase): 19 | @parameterized.expand(normal) 20 | def test_timeline(self, username): 21 | self.open_nitter(username) 22 | self.assert_element_present(Timeline.older) 23 | self.assert_element_absent(Timeline.newest) 24 | self.assert_element_absent(Timeline.end) 25 | self.assert_element_absent(Timeline.none) 26 | 27 | @parameterized.expand(after) 28 | def test_after(self, username, cursor): 29 | self.open_nitter(f'{username}?cursor={cursor}') 30 | self.assert_element_present(Timeline.newest) 31 | self.assert_element_present(Timeline.older) 32 | self.assert_element_absent(Timeline.end) 33 | self.assert_element_absent(Timeline.none) 34 | 35 | @parameterized.expand(no_more) 36 | def test_no_more(self, username): 37 | self.open_nitter(username) 38 | self.assert_text('No more items', Timeline.end) 39 | self.assert_element_present(Timeline.newest) 40 | self.assert_element_absent(Timeline.older) 41 | 42 | @parameterized.expand(empty) 43 | def test_empty(self, username): 44 | self.open_nitter(username) 45 | self.assert_text('No items found', Timeline.none) 46 | self.assert_element_absent(Timeline.newest) 47 | self.assert_element_absent(Timeline.older) 48 | self.assert_element_absent(Timeline.end) 49 | 50 | @parameterized.expand(protected) 51 | def test_protected(self, username): 52 | self.open_nitter(username) 53 | self.assert_text('This account\'s tweets are protected.', Timeline.protected) 54 | self.assert_element_absent(Timeline.newest) 55 | self.assert_element_absent(Timeline.older) 56 | self.assert_element_absent(Timeline.end) 57 | 58 | #@parameterized.expand(photo_rail) 59 | #def test_photo_rail(self, username, images): 60 | #self.open_nitter(username) 61 | #self.assert_element_visible(Timeline.photo_rail) 62 | #for i, url in enumerate(images): 63 | #img = self.get_attribute(Timeline.photo_rail + f' a:nth-child({i + 1}) img', 'src') 64 | #self.assertIn(url, img) 65 | -------------------------------------------------------------------------------- /src/sass/tweet/media.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | .gallery-row { 4 | display: flex; 5 | flex-direction: row; 6 | flex-wrap: nowrap; 7 | align-items: center; 8 | overflow: hidden; 9 | flex-grow: 1; 10 | max-height: 379.5px; 11 | max-width: 533px; 12 | pointer-events: all; 13 | 14 | .still-image { 15 | width: 100%; 16 | display: flex; 17 | } 18 | } 19 | 20 | .attachments { 21 | margin-top: .35em; 22 | display: flex; 23 | flex-direction: row; 24 | width: 100%; 25 | max-height: 600px; 26 | border-radius: 7px; 27 | overflow: hidden; 28 | flex-flow: column; 29 | background-color: var(--bg_color); 30 | align-items: center; 31 | pointer-events: all; 32 | 33 | .image-attachment { 34 | width: 100%; 35 | } 36 | } 37 | 38 | .attachment { 39 | position: relative; 40 | line-height: 0; 41 | overflow: hidden; 42 | margin: 0 .25em 0 0; 43 | flex-grow: 1; 44 | box-sizing: border-box; 45 | min-width: 2em; 46 | 47 | &:last-child { 48 | margin: 0; 49 | max-height: 530px; 50 | } 51 | } 52 | 53 | .gallery-gif video { 54 | max-height: 530px; 55 | background-color: #101010; 56 | } 57 | 58 | .still-image { 59 | max-height: 379.5px; 60 | max-width: 533px; 61 | justify-content: center; 62 | 63 | img { 64 | object-fit: cover; 65 | max-width: 100%; 66 | max-height: 379.5px; 67 | flex-basis: 300px; 68 | flex-grow: 1; 69 | } 70 | } 71 | 72 | .image { 73 | display: inline-block; 74 | } 75 | 76 | // .single-image { 77 | // display: inline-block; 78 | // width: 100%; 79 | // max-height: 600px; 80 | 81 | // .attachments { 82 | // width: unset; 83 | // max-height: unset; 84 | // display: inherit; 85 | // } 86 | // } 87 | 88 | .overlay-circle { 89 | border-radius: 50%; 90 | background-color: var(--dark_grey); 91 | width: 40px; 92 | height: 40px; 93 | align-items: center; 94 | display: flex; 95 | border-width: 5px; 96 | border-color: var(--play_button); 97 | border-style: solid; 98 | } 99 | 100 | .overlay-triangle { 101 | width: 0; 102 | height: 0; 103 | border-style: solid; 104 | border-width: 12px 0 12px 17px; 105 | border-color: transparent transparent transparent var(--play_button); 106 | margin-left: 14px; 107 | } 108 | 109 | .media-gif { 110 | display: table; 111 | background-color: unset; 112 | width: unset; 113 | } 114 | 115 | .media-body { 116 | flex: 1; 117 | padding: 0; 118 | white-space: pre-wrap; 119 | } 120 | -------------------------------------------------------------------------------- /src/routes/status.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import asyncdispatch, strutils, sequtils, uri, options, sugar 3 | 4 | import jester, karax/vdom 5 | 6 | import router_utils 7 | import ".."/[types, formatters, api] 8 | import ../views/[general, status] 9 | 10 | export uri, sequtils, options, sugar 11 | export router_utils 12 | export api, formatters 13 | export status 14 | 15 | proc createStatusRouter*(cfg: Config) = 16 | router status: 17 | get "/@name/status/@id/?": 18 | cond '.' notin @"name" 19 | let id = @"id" 20 | 21 | if id.len > 19 or id.any(c => not c.isDigit): 22 | resp Http404, showError("Invalid tweet ID", cfg) 23 | 24 | let prefs = cookiePrefs() 25 | 26 | # used for the infinite scroll feature 27 | if @"scroll".len > 0: 28 | let replies = await getReplies(id, getCursor()) 29 | if replies.content.len == 0: 30 | resp Http404, "" 31 | resp $renderReplies(replies, prefs, getPath()) 32 | 33 | let conv = await getTweet(id, getCursor()) 34 | if conv == nil: 35 | echo "nil conv" 36 | 37 | if conv == nil or conv.tweet == nil or conv.tweet.id == 0: 38 | var error = "Tweet not found" 39 | if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0: 40 | error = conv.tweet.tombstone 41 | resp Http404, showError(error, cfg) 42 | 43 | let 44 | title = pageTitle(conv.tweet) 45 | ogTitle = pageTitle(conv.tweet.user) 46 | desc = conv.tweet.text 47 | 48 | var 49 | images = conv.tweet.photos 50 | video = "" 51 | 52 | if conv.tweet.video.isSome(): 53 | images = @[get(conv.tweet.video).thumb] 54 | video = getVideoEmbed(cfg, conv.tweet.id) 55 | elif conv.tweet.gif.isSome(): 56 | images = @[get(conv.tweet.gif).thumb] 57 | video = getPicUrl(get(conv.tweet.gif).url) 58 | elif conv.tweet.card.isSome(): 59 | let card = conv.tweet.card.get() 60 | if card.image.len > 0: 61 | images = @[card.image] 62 | elif card.video.isSome(): 63 | images = @[card.video.get().thumb] 64 | 65 | let html = renderConversation(conv, prefs, getPath() & "#m") 66 | resp renderMain(html, request, cfg, prefs, title, desc, ogTitle, 67 | images=images, video=video) 68 | 69 | get "/@name/@s/@id/@m/?@i?": 70 | cond @"s" in ["status", "statuses"] 71 | cond @"m" in ["video", "photo"] 72 | redirect("/$1/status/$2" % [@"name", @"id"]) 73 | 74 | get "/@name/statuses/@id/?": 75 | redirect("/$1/status/$2" % [@"name", @"id"]) 76 | 77 | get "/i/web/status/@id": 78 | redirect("/i/status/" & @"id") 79 | 80 | get "/@name/thread/@id/?": 81 | redirect("/$1/status/$2" % [@"name", @"id"]) 82 | -------------------------------------------------------------------------------- /src/sass/search.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | .search-title { 5 | font-weight: bold; 6 | display: inline-block; 7 | margin-top: 4px; 8 | } 9 | 10 | .search-field { 11 | display: flex; 12 | flex-wrap: wrap; 13 | 14 | button { 15 | margin: 0 2px 0 0; 16 | height: 23px; 17 | display: flex; 18 | align-items: center; 19 | } 20 | 21 | .pref-input { 22 | margin: 0 4px 0 0; 23 | flex-grow: 1; 24 | height: 23px; 25 | } 26 | 27 | input[type="text"] { 28 | height: calc(100% - 4px); 29 | width: calc(100% - 8px); 30 | } 31 | 32 | > label { 33 | display: inline; 34 | background-color: var(--bg_elements); 35 | color: var(--fg_color); 36 | border: 1px solid var(--accent_border); 37 | padding: 1px 6px 2px 6px; 38 | font-size: 14px; 39 | cursor: pointer; 40 | margin-bottom: 2px; 41 | 42 | @include input-colors; 43 | } 44 | 45 | @include create-toggle(search-panel, 200px); 46 | } 47 | 48 | .search-panel { 49 | width: 100%; 50 | max-height: 0; 51 | overflow: hidden; 52 | transition: max-height 0.4s; 53 | 54 | flex-grow: 1; 55 | font-weight: initial; 56 | text-align: left; 57 | 58 | > div { 59 | line-height: 1.7em; 60 | } 61 | 62 | .checkbox-container { 63 | display: inline; 64 | padding-right: unset; 65 | margin-bottom: unset; 66 | margin-left: 23px; 67 | } 68 | 69 | .checkbox { 70 | right: unset; 71 | left: -22px; 72 | } 73 | 74 | .checkbox-container .checkbox:after { 75 | top: -4px; 76 | } 77 | } 78 | 79 | .search-row { 80 | display: flex; 81 | flex-wrap: wrap; 82 | line-height: unset; 83 | 84 | > div { 85 | flex-grow: 1; 86 | flex-shrink: 1; 87 | } 88 | 89 | input { 90 | height: 21px; 91 | } 92 | 93 | .pref-input { 94 | display: block; 95 | padding-bottom: 5px; 96 | 97 | input { 98 | height: 21px; 99 | margin-top: 1px; 100 | } 101 | } 102 | } 103 | 104 | .search-toggles { 105 | flex-grow: 1; 106 | display: grid; 107 | grid-template-columns: repeat(6, auto); 108 | grid-column-gap: 10px; 109 | } 110 | 111 | .profile-tabs { 112 | @include search-resize(820px, 5); 113 | @include search-resize(725px, 4); 114 | @include search-resize(600px, 6); 115 | @include search-resize(560px, 5); 116 | @include search-resize(480px, 4); 117 | @include search-resize(410px, 3); 118 | } 119 | 120 | @include search-resize(560px, 5); 121 | @include search-resize(480px, 4); 122 | @include search-resize(410px, 3); 123 | -------------------------------------------------------------------------------- /src/sass/profile/card.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | .profile-card { 5 | flex-wrap: wrap; 6 | background: var(--bg_panel); 7 | padding: 12px; 8 | display: flex; 9 | } 10 | 11 | .profile-card-info { 12 | @include breakable; 13 | width: 100%; 14 | } 15 | 16 | .profile-card-tabs-name { 17 | @include breakable; 18 | max-width: 100%; 19 | } 20 | 21 | .profile-card-username { 22 | @include breakable; 23 | color: var(--fg_color); 24 | font-size: 14px; 25 | display: block; 26 | } 27 | 28 | .profile-card-fullname { 29 | @include breakable; 30 | color: var(--fg_color); 31 | font-size: 16px; 32 | font-weight: bold; 33 | text-shadow: none; 34 | max-width: 100%; 35 | } 36 | 37 | .profile-card-avatar { 38 | display: inline-block; 39 | position: relative; 40 | width: 100%; 41 | margin-right: 4px; 42 | margin-bottom: 6px; 43 | 44 | &:after { 45 | content: ''; 46 | display: block; 47 | margin-top: 100%; 48 | } 49 | 50 | img { 51 | box-sizing: border-box; 52 | position: absolute; 53 | width: 100%; 54 | height: 100%; 55 | border: 4px solid var(--darker_grey); 56 | background: var(--bg_panel); 57 | } 58 | } 59 | 60 | .profile-card-extra { 61 | display: contents; 62 | flex: 100%; 63 | margin-top: 7px; 64 | 65 | .profile-bio { 66 | @include breakable; 67 | width: 100%; 68 | margin: 4px -6px 6px 0; 69 | white-space: pre-wrap; 70 | 71 | p { 72 | margin: 0; 73 | } 74 | } 75 | 76 | .profile-joindate, .profile-location, .profile-website { 77 | color: var(--fg_faded); 78 | margin: 1px 0; 79 | width: 100%; 80 | } 81 | } 82 | 83 | .profile-card-extra-links { 84 | margin-top: 8px; 85 | font-size: 14px; 86 | width: 100%; 87 | } 88 | 89 | .profile-statlist { 90 | display: flex; 91 | flex-wrap: wrap; 92 | padding: 0; 93 | width: 100%; 94 | justify-content: space-between; 95 | 96 | li { 97 | display: table-cell; 98 | text-align: center; 99 | } 100 | } 101 | 102 | .profile-stat-header { 103 | font-weight: bold; 104 | color: var(--profile_stat); 105 | } 106 | 107 | .profile-stat-num { 108 | display: block; 109 | color: var(--profile_stat); 110 | } 111 | 112 | @media(max-width: 700px) { 113 | .profile-card-info { 114 | display: flex; 115 | } 116 | 117 | .profile-card-tabs-name { 118 | flex-shrink: 100; 119 | } 120 | 121 | .profile-card-avatar { 122 | width: 80px; 123 | height: 80px; 124 | 125 | img { 126 | border-width: 2px; 127 | width: unset; 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tests/test_profile.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Profile 2 | from parameterized import parameterized 3 | 4 | profiles = [ 5 | ['mobile_test', 'Test account', 6 | 'Test Account. test test Testing username with @mobile_test_2 and a #hashtag', 7 | 'San Francisco, CA', 'example.com/foobar', 'Joined October 2009', '98'], 8 | ['mobile_test_2', 'mobile test 2', '', '', '', 'Joined January 2011', '13'] 9 | ] 10 | 11 | verified = [['jack'], ['elonmusk']] 12 | 13 | protected = [ 14 | ['mobile_test_7', 'mobile test 7', ''], 15 | ['Poop', 'Randy', 'Social media fanatic.'] 16 | ] 17 | 18 | invalid = [['thisprofiledoesntexist'], ['%']] 19 | 20 | banner_image = [ 21 | ['mobile_test', 'profile_banners%2F82135242%2F1384108037%2F1500x500'] 22 | ] 23 | 24 | 25 | class ProfileTest(BaseTestCase): 26 | @parameterized.expand(profiles) 27 | def test_data(self, username, fullname, bio, location, website, joinDate, mediaCount): 28 | self.open_nitter(username) 29 | self.assert_exact_text(fullname, Profile.fullname) 30 | self.assert_exact_text(f'@{username}', Profile.username) 31 | 32 | tests = [ 33 | (bio, Profile.bio), 34 | (location, Profile.location), 35 | (website, Profile.website), 36 | (joinDate, Profile.joinDate), 37 | (mediaCount + " Photos and videos", Profile.mediaCount) 38 | ] 39 | 40 | for text, selector in tests: 41 | if len(text) > 0: 42 | self.assert_exact_text(text, selector) 43 | else: 44 | self.assert_element_absent(selector) 45 | 46 | @parameterized.expand(verified) 47 | def test_verified(self, username): 48 | self.open_nitter(username) 49 | self.assert_element_visible(Profile.verified) 50 | 51 | @parameterized.expand(protected) 52 | def test_protected(self, username, fullname, bio): 53 | self.open_nitter(username) 54 | self.assert_element_visible(Profile.protected) 55 | self.assert_exact_text(fullname, Profile.fullname) 56 | self.assert_exact_text(f'@{username}', Profile.username) 57 | 58 | if len(bio) > 0: 59 | self.assert_text(bio, Profile.bio) 60 | else: 61 | self.assert_element_absent(Profile.bio) 62 | 63 | @parameterized.expand(invalid) 64 | def test_invalid_username(self, username): 65 | self.open_nitter(username) 66 | self.assert_text(f'User "{username}" not found') 67 | 68 | def test_suspended(self): 69 | self.open_nitter('suspendme') 70 | self.assert_text('User "suspendme" has been suspended') 71 | 72 | @parameterized.expand(banner_image) 73 | def test_banner_image(self, username, url): 74 | self.open_nitter(username) 75 | banner = self.find_element(Profile.banner + ' img') 76 | self.assertIn(url, banner.get_attribute('src')) 77 | -------------------------------------------------------------------------------- /tests/integration.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import requests 4 | 5 | # Stop and start the docker compose stack 6 | os.system("docker compose -f docker-compose.yml down") 7 | os.system("docker compose -f docker-compose.yml up --build -d") 8 | 9 | # Long poll (max 5 minutes) localhost:8081 until it returns 401 10 | root_url = "http://localhost:8081" 11 | long_poll_timeout = 5 * 60 # 5 minutes in seconds 12 | long_poll_interval = 5 # Interval between requests in seconds 13 | long_poll_start_time = time.time() 14 | 15 | while time.time() - long_poll_start_time < long_poll_timeout: 16 | try: 17 | print(f"Polling {root_url} for 401") 18 | long_poll_resp = requests.get(root_url) 19 | if long_poll_resp.status_code == 401: 20 | print("Received 401") 21 | break 22 | except requests.RequestException as e: 23 | print(f"Request failed: {e}") 24 | 25 | time.sleep(long_poll_interval) 26 | else: 27 | print("Timeout reached without receiving 401") 28 | 29 | # Test RSS feed unauthenticated. Should return 200 but empty body. 30 | print("Testing RSS feed unauthenticated") 31 | unauthenticated_rss_url = f"{root_url}/elonmusk/rss" 32 | unauthenticated_rss_resp = requests.get(unauthenticated_rss_url) 33 | assert unauthenticated_rss_resp.status_code == 200 34 | assert unauthenticated_rss_resp.text == "" 35 | 36 | # Get RSS key from .env file 37 | with open(".env") as f: 38 | for line in f: 39 | if line.startswith("INSTANCE_RSS_PASSWORD"): 40 | rss_key = line.split("=")[1].strip() 41 | 42 | # Test RSS feed authenticated. Should return 200 with non-empty body. 43 | print("Testing RSS feed authenticated") 44 | authenticated_rss_url = f"{root_url}/elonmusk/rss?key={rss_key}" 45 | authenticated_rss_resp = requests.get(authenticated_rss_url) 46 | assert authenticated_rss_resp.status_code == 200 47 | assert authenticated_rss_resp.text != "" 48 | 49 | # Test web UI unauthenticated. Should return 401. 50 | print("Testing web UI unauthenticated") 51 | web_ui_url = f"{root_url}/elonmusk" 52 | unauthenticated_web_ui_resp = requests.get(web_ui_url) 53 | assert unauthenticated_web_ui_resp.status_code == 401 54 | 55 | # Get web UI username and password from .env file 56 | with open(".env") as f: 57 | for line in f: 58 | if line.startswith("INSTANCE_WEB_USERNAME"): 59 | username = line.split("=")[1].strip() 60 | elif line.startswith("INSTANCE_WEB_PASSWORD"): 61 | password = line.split("=")[1].strip() 62 | 63 | # Test web UI authenticated. Should return 200 with non-empty body. 64 | print("Testing web UI authenticated") 65 | authenticated_web_ui_resp = requests.get(web_ui_url, auth=(username, password)) 66 | assert authenticated_web_ui_resp.status_code == 200 67 | assert authenticated_web_ui_resp.text != "" 68 | 69 | # Export logs 70 | os.system("docker compose -f docker-compose.yml logs > integration-test.logs") 71 | 72 | # Stop the docker compose stack 73 | os.system("docker compose -f docker-compose.yml down") 74 | -------------------------------------------------------------------------------- /src/views/status.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import karax/[karaxdsl, vdom] 3 | 4 | import ".."/[types, formatters] 5 | import tweet, timeline 6 | 7 | proc renderEarlier(thread: Chain): VNode = 8 | buildHtml(tdiv(class="timeline-item more-replies earlier-replies")): 9 | a(class="more-replies-text", href=getLink(thread.content[0])): 10 | text "earlier replies" 11 | 12 | proc renderMoreReplies(thread: Chain): VNode = 13 | let link = getLink(thread.content[^1]) 14 | buildHtml(tdiv(class="timeline-item more-replies")): 15 | if thread.content[^1].available: 16 | a(class="more-replies-text", href=link): 17 | text "more replies" 18 | else: 19 | a(class="more-replies-text"): 20 | text "more replies" 21 | 22 | proc renderReplyThread(thread: Chain; prefs: Prefs; path: string): VNode = 23 | buildHtml(tdiv(class="reply thread thread-line")): 24 | for i, tweet in thread.content: 25 | let last = (i == thread.content.high and not thread.hasMore) 26 | renderTweet(tweet, prefs, path, index=i, last=last) 27 | 28 | if thread.hasMore: 29 | renderMoreReplies(thread) 30 | 31 | proc renderReplies*(replies: Result[Chain]; prefs: Prefs; path: string): VNode = 32 | buildHtml(tdiv(class="replies", id="r")): 33 | for thread in replies.content: 34 | if thread.content.len == 0: continue 35 | renderReplyThread(thread, prefs, path) 36 | 37 | if replies.bottom.len > 0: 38 | renderMore(Query(), replies.bottom, focus="#r") 39 | 40 | proc renderConversation*(conv: Conversation; prefs: Prefs; path: string): VNode = 41 | let hasAfter = conv.after.content.len > 0 42 | let threadId = conv.tweet.threadId 43 | buildHtml(tdiv(class="conversation")): 44 | tdiv(class="main-thread"): 45 | if conv.before.content.len > 0: 46 | tdiv(class="before-tweet thread-line"): 47 | let first = conv.before.content[0] 48 | if threadId != first.id and (first.replyId > 0 or not first.available): 49 | renderEarlier(conv.before) 50 | for i, tweet in conv.before.content: 51 | renderTweet(tweet, prefs, path, index=i) 52 | 53 | tdiv(class="main-tweet", id="m"): 54 | let afterClass = if hasAfter: "thread thread-line" else: "" 55 | renderTweet(conv.tweet, prefs, path, class=afterClass, mainTweet=true) 56 | 57 | if hasAfter: 58 | tdiv(class="after-tweet thread-line"): 59 | let 60 | total = conv.after.content.high 61 | hasMore = conv.after.hasMore 62 | for i, tweet in conv.after.content: 63 | renderTweet(tweet, prefs, path, index=i, 64 | last=(i == total and not hasMore), afterTweet=true) 65 | 66 | if hasMore: 67 | renderMoreReplies(conv.after) 68 | 69 | if not prefs.hideReplies: 70 | if not conv.replies.beginning: 71 | renderNewer(Query(), getLink(conv.tweet), focus="#r") 72 | if conv.replies.content.len > 0 or conv.replies.bottom.len > 0: 73 | renderReplies(conv.replies, prefs, path) 74 | 75 | renderToTop(focus="#m") 76 | -------------------------------------------------------------------------------- /scripts/gen_nginx_conf.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from typing import Tuple 4 | from passlib.apache import HtpasswdFile 5 | 6 | 7 | RSS_PASSWORD_PLZ_CHANGE = "[RSS_PASSWORD_PLZ_CHANGE]" 8 | HTPASSWD_FILE_PLZ_CHANGE = "[HTPASSWD_FILE_PLZ_CHANGE]" 9 | TEMPLATE = """server { 10 | listen 8081; 11 | 12 | location ~* /rss$ { 13 | if ($arg_key != [RSS_PASSWORD_PLZ_CHANGE]) { 14 | return 200 ''; 15 | } 16 | 17 | proxy_pass http://localhost:8080; 18 | } 19 | 20 | location /pic/ { proxy_pass http://localhost:8080; } 21 | location /video/ { proxy_pass http://localhost:8080; } 22 | 23 | location /css/ { proxy_pass http://localhost:8080; } 24 | 25 | location /js/ { proxy_pass http://localhost:8080; } 26 | location /fonts/ { proxy_pass http://localhost:8080; } 27 | location = /apple-touch-icon.png { proxy_pass http://localhost:8080; } 28 | location = /apple-touch-icon-precomposed.png { proxy_pass http://localhost:8080; } 29 | location = /android-chrome-192x192.png { proxy_pass http://localhost:8080; } 30 | location = /favicon-32x32.png { proxy_pass http://localhost:8080; } 31 | location = /favicon-16x16.png { proxy_pass http://localhost:8080; } 32 | location = /favicon.ico { proxy_pass http://localhost:8080; } 33 | location = /logo.png { proxy_pass http://localhost:8080; } 34 | location = /site.webmanifest { proxy_pass http://localhost:8080; } 35 | 36 | location / { 37 | auth_basic "Restricted Content"; 38 | auth_basic_user_file [HTPASSWD_FILE_PLZ_CHANGE]; 39 | 40 | proxy_pass http://localhost:8080; 41 | } 42 | }""" 43 | 44 | def main(rss_password: str, web_username: str, web_password: str, htpasswd_file: str) -> Tuple[str, str]: 45 | site_conf = TEMPLATE.replace(RSS_PASSWORD_PLZ_CHANGE, rss_password).replace(HTPASSWD_FILE_PLZ_CHANGE, htpasswd_file) 46 | ht = HtpasswdFile() 47 | ht.set_password(web_username, web_password) 48 | htpasswd = ht.to_string().decode('utf-8') 49 | 50 | return (site_conf, htpasswd) 51 | 52 | 53 | if __name__ == "__main__": 54 | if len(sys.argv) != 3: 55 | print("Usage: python3 gen_nginx_conf.py ") 56 | sys.exit(1) 57 | 58 | site_conf_file = sys.argv[1] 59 | htpasswd_file = sys.argv[2] 60 | 61 | rss_password = os.getenv("INSTANCE_RSS_PASSWORD") 62 | if not rss_password: 63 | print("Please set environment variable INSTANCE_RSS_PASSWORD") 64 | sys.exit(1) 65 | web_username = os.getenv("INSTANCE_WEB_USERNAME") 66 | if not web_username: 67 | print("Please set environment variable INSTANCE_WEB_USERNAME") 68 | sys.exit(1) 69 | web_password = os.getenv("INSTANCE_WEB_PASSWORD") 70 | if not web_password: 71 | print("Please set environment variable INSTANCE_WEB_PASSWORD") 72 | sys.exit(1) 73 | 74 | (site_conf, htpasswd) = main(rss_password, web_username, web_password, htpasswd_file) 75 | 76 | with open(site_conf_file, "w") as f: 77 | f.write(site_conf) 78 | with open(htpasswd_file, "w") as f: 79 | f.write(htpasswd) 80 | -------------------------------------------------------------------------------- /src/experimental/parser/user.nim: -------------------------------------------------------------------------------- 1 | import std/[algorithm, unicode, re, strutils, strformat, options, nre] 2 | import jsony 3 | import utils, slices 4 | import ../types/user as userType 5 | from ../../types import Result, User, Error 6 | 7 | let 8 | unRegex = re.re"(^|[^A-z0-9-_./?])@([A-z0-9_]{1,15})" 9 | unReplace = "$1@$2" 10 | 11 | htRegex = nre.re"""(*U)(^|[^\w-_.?])([##$])([\w_]*+)(?!|">|#)""" 12 | htReplace = "$1$2$3" 13 | 14 | proc expandUserEntities(user: var User; raw: RawUser) = 15 | let 16 | orig = user.bio.toRunes 17 | ent = raw.entities 18 | 19 | if ent.url.urls.len > 0: 20 | user.website = ent.url.urls[0].expandedUrl 21 | 22 | var replacements = newSeq[ReplaceSlice]() 23 | 24 | for u in ent.description.urls: 25 | replacements.extractUrls(u, orig.high) 26 | 27 | replacements.dedupSlices 28 | replacements.sort(cmp) 29 | 30 | user.bio = orig.replacedWith(replacements, 0 .. orig.len) 31 | .replacef(unRegex, unReplace) 32 | .replace(htRegex, htReplace) 33 | 34 | proc getBanner(user: RawUser): string = 35 | if user.profileBannerUrl.len > 0: 36 | return user.profileBannerUrl & "/1500x500" 37 | 38 | if user.profileLinkColor.len > 0: 39 | return '#' & user.profileLinkColor 40 | 41 | if user.profileImageExtensions.isSome: 42 | let ext = get(user.profileImageExtensions) 43 | if ext.mediaColor.r.ok.palette.len > 0: 44 | let color = ext.mediaColor.r.ok.palette[0].rgb 45 | return &"#{color.red:02x}{color.green:02x}{color.blue:02x}" 46 | 47 | proc toUser*(raw: RawUser): User = 48 | result = User( 49 | id: raw.idStr, 50 | username: raw.screenName, 51 | fullname: raw.name, 52 | location: raw.location, 53 | bio: raw.description, 54 | following: raw.friendsCount, 55 | followers: raw.followersCount, 56 | tweets: raw.statusesCount, 57 | likes: raw.favouritesCount, 58 | media: raw.mediaCount, 59 | verifiedType: raw.verifiedType, 60 | protected: raw.protected, 61 | joinDate: parseTwitterDate(raw.createdAt), 62 | banner: getBanner(raw), 63 | userPic: getImageUrl(raw.profileImageUrlHttps).replace("_normal", "") 64 | ) 65 | 66 | if raw.pinnedTweetIdsStr.len > 0: 67 | result.pinnedTweet = parseBiggestInt(raw.pinnedTweetIdsStr[0]) 68 | 69 | result.expandUserEntities(raw) 70 | 71 | proc parseHook*(s: string; i: var int; v: var User) = 72 | var u: RawUser 73 | parseHook(s, i, u) 74 | v = toUser u 75 | 76 | proc parseUser*(json: string; username=""): User = 77 | handleErrors: 78 | case error.code 79 | of suspended: return User(username: username, suspended: true) 80 | of userNotFound: return 81 | else: echo "[error - parseUser]: ", error 82 | 83 | result = json.fromJson(User) 84 | 85 | proc parseUsers*(json: string; after=""): Result[User] = 86 | result = Result[User](beginning: after.len == 0) 87 | 88 | # starting with '{' means it's an error 89 | if json[0] == '[': 90 | let raw = json.fromJson(seq[RawUser]) 91 | for user in raw: 92 | result.content.add user.toUser 93 | -------------------------------------------------------------------------------- /public/js/infiniteScroll.js: -------------------------------------------------------------------------------- 1 | // @license http://www.gnu.org/licenses/agpl-3.0.html AGPL-3.0 2 | // SPDX-License-Identifier: AGPL-3.0-only 3 | function insertBeforeLast(node, elem) { 4 | node.insertBefore(elem, node.childNodes[node.childNodes.length - 2]); 5 | } 6 | 7 | function getLoadMore(doc) { 8 | return doc.querySelector(".show-more:not(.timeline-item)"); 9 | } 10 | 11 | function isDuplicate(item, itemClass) { 12 | const tweet = item.querySelector(".tweet-link"); 13 | if (tweet == null) return false; 14 | const href = tweet.getAttribute("href"); 15 | return document.querySelector(itemClass + " .tweet-link[href='" + href + "']") != null; 16 | } 17 | 18 | window.onload = function () { 19 | const url = window.location.pathname; 20 | const isTweet = url.indexOf("/status/") !== -1; 21 | const containerClass = isTweet ? ".replies" : ".timeline"; 22 | const itemClass = containerClass + " > div:not(.top-ref)"; 23 | 24 | var html = document.querySelector("html"); 25 | var container = document.querySelector(containerClass); 26 | var loading = false; 27 | 28 | function handleScroll(failed) { 29 | if (loading) return; 30 | 31 | if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) { 32 | loading = true; 33 | var loadMore = getLoadMore(document); 34 | if (loadMore == null) return; 35 | 36 | loadMore.children[0].text = "Loading..."; 37 | 38 | var url = new URL(loadMore.children[0].href); 39 | url.searchParams.append("scroll", "true"); 40 | 41 | fetch(url.toString()).then(function (response) { 42 | if (response.status === 404) throw "error"; 43 | 44 | return response.text(); 45 | }).then(function (html) { 46 | var parser = new DOMParser(); 47 | var doc = parser.parseFromString(html, "text/html"); 48 | loadMore.remove(); 49 | 50 | for (var item of doc.querySelectorAll(itemClass)) { 51 | if (item.className == "timeline-item show-more") continue; 52 | if (isDuplicate(item, itemClass)) continue; 53 | if (isTweet) container.appendChild(item); 54 | else insertBeforeLast(container, item); 55 | } 56 | 57 | loading = false; 58 | const newLoadMore = getLoadMore(doc); 59 | if (newLoadMore == null) return; 60 | if (isTweet) container.appendChild(newLoadMore); 61 | else insertBeforeLast(container, newLoadMore); 62 | }).catch(function (err) { 63 | console.warn("Something went wrong.", err); 64 | if (failed > 3) { 65 | loadMore.children[0].text = "Error"; 66 | return; 67 | } 68 | 69 | loading = false; 70 | handleScroll((failed || 0) + 1); 71 | }); 72 | } 73 | } 74 | 75 | window.addEventListener("scroll", () => handleScroll()); 76 | }; 77 | // @license-end 78 | -------------------------------------------------------------------------------- /src/sass/tweet/thread.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | .conversation { 5 | @include panel(100%, 600px); 6 | 7 | .show-more { 8 | margin-bottom: 10px; 9 | } 10 | } 11 | 12 | .main-thread { 13 | margin-bottom: 20px; 14 | background-color: var(--bg_panel); 15 | } 16 | 17 | .main-tweet, .replies { 18 | padding-top: 50px; 19 | margin-top: -50px; 20 | } 21 | 22 | .main-tweet .tweet-content { 23 | font-size: 18px; 24 | } 25 | 26 | @media(max-width: 600px) { 27 | .main-tweet .tweet-content { 28 | font-size: 16px; 29 | } 30 | } 31 | 32 | .reply { 33 | background-color: var(--bg_panel); 34 | margin-bottom: 10px; 35 | } 36 | 37 | .thread-line { 38 | .timeline-item::before, 39 | &.timeline-item::before { 40 | background: var(--accent_dark); 41 | content: ''; 42 | position: relative; 43 | min-width: 3px; 44 | width: 3px; 45 | left: 26px; 46 | border-radius: 2px; 47 | margin-left: -3px; 48 | margin-bottom: 37px; 49 | top: 56px; 50 | z-index: 1; 51 | pointer-events: none; 52 | } 53 | 54 | .with-header:not(:first-child)::after { 55 | background: var(--accent_dark); 56 | content: ''; 57 | position: relative; 58 | float: left; 59 | min-width: 3px; 60 | width: 3px; 61 | right: calc(100% - 26px); 62 | border-radius: 2px; 63 | margin-left: -3px; 64 | margin-bottom: 37px; 65 | bottom: 10px; 66 | height: 30px; 67 | z-index: 1; 68 | pointer-events: none; 69 | } 70 | 71 | .unavailable::before { 72 | top: 48px; 73 | margin-bottom: 28px; 74 | } 75 | 76 | .more-replies::before { 77 | content: '...'; 78 | background: unset; 79 | color: var(--more_replies_dots); 80 | font-weight: bold; 81 | font-size: 20px; 82 | line-height: 0.25em; 83 | left: 1.2em; 84 | width: 5px; 85 | top: 2px; 86 | margin-bottom: 0; 87 | margin-left: -2.5px; 88 | } 89 | 90 | .earlier-replies { 91 | padding-bottom: 0; 92 | margin-bottom: -5px; 93 | } 94 | } 95 | 96 | .timeline-item.thread-last::before { 97 | background: unset; 98 | min-width: unset; 99 | width: 0; 100 | margin: 0; 101 | } 102 | 103 | .more-replies { 104 | padding-top: 0.3em !important; 105 | } 106 | 107 | .more-replies-text { 108 | @include ellipsis; 109 | display: block; 110 | margin-left: 58px; 111 | padding: 7px 0; 112 | } 113 | 114 | .timeline-item.thread.more-replies-thread { 115 | padding: 0 0.75em; 116 | 117 | &::before { 118 | top: 40px; 119 | margin-bottom: 31px; 120 | } 121 | 122 | .more-replies { 123 | display: flex; 124 | padding-top: unset !important; 125 | margin-top: 8px; 126 | 127 | &::before { 128 | display: inline-block; 129 | position: relative; 130 | top: -1px; 131 | line-height: 0.4em; 132 | } 133 | 134 | .more-replies-text { 135 | display: inline; 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/query.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat, sequtils, tables, uri 3 | 4 | import types 5 | 6 | const 7 | validFilters* = @[ 8 | "media", "images", "twimg", "videos", 9 | "native_video", "consumer_video", "pro_video", 10 | "links", "news", "quote", "mentions", 11 | "replies", "retweets", "nativeretweets", 12 | "verified", "safe" 13 | ] 14 | 15 | emptyQuery* = "include:nativeretweets" 16 | 17 | template `@`(param: string): untyped = 18 | if param in pms: pms[param] 19 | else: "" 20 | 21 | proc initQuery*(pms: Table[string, string]; name=""): Query = 22 | result = Query( 23 | kind: parseEnum[QueryKind](@"f", tweets), 24 | text: @"q", 25 | filters: validFilters.filterIt("f-" & it in pms), 26 | excludes: validFilters.filterIt("e-" & it in pms), 27 | since: @"since", 28 | until: @"until", 29 | near: @"near" 30 | ) 31 | 32 | if name.len > 0: 33 | result.fromUser = name.split(",") 34 | 35 | proc getMediaQuery*(name: string): Query = 36 | Query( 37 | kind: media, 38 | filters: @["twimg", "native_video"], 39 | fromUser: @[name], 40 | sep: "OR" 41 | ) 42 | 43 | proc getReplyQuery*(name: string): Query = 44 | Query( 45 | kind: replies, 46 | fromUser: @[name] 47 | ) 48 | 49 | proc genQueryParam*(query: Query): string = 50 | var 51 | filters: seq[string] 52 | param: string 53 | 54 | if query.kind == users: 55 | return query.text 56 | 57 | for i, user in query.fromUser: 58 | param &= &"from:{user} " 59 | if i < query.fromUser.high: 60 | param &= "OR " 61 | 62 | if query.fromUser.len > 0 and query.kind in {posts, media}: 63 | param &= "filter:self_threads OR -filter:replies " 64 | 65 | if "nativeretweets" notin query.excludes: 66 | param &= "include:nativeretweets " 67 | 68 | for f in query.filters: 69 | filters.add "filter:" & f 70 | for e in query.excludes: 71 | if e == "nativeretweets": continue 72 | filters.add "-filter:" & e 73 | for i in query.includes: 74 | filters.add "include:" & i 75 | 76 | result = strip(param & filters.join(&" {query.sep} ")) 77 | if query.since.len > 0: 78 | result &= " since:" & query.since 79 | if query.until.len > 0: 80 | result &= " until:" & query.until 81 | if query.near.len > 0: 82 | result &= &" near:\"{query.near}\" within:15mi" 83 | if query.text.len > 0: 84 | if result.len > 0: 85 | result &= " " & query.text 86 | else: 87 | result = query.text 88 | 89 | proc genQueryUrl*(query: Query): string = 90 | if query.kind notin {tweets, users}: return 91 | 92 | var params = @[&"f={query.kind}"] 93 | if query.text.len > 0: 94 | params.add "q=" & encodeUrl(query.text) 95 | for f in query.filters: 96 | params.add &"f-{f}=on" 97 | for e in query.excludes: 98 | params.add &"e-{e}=on" 99 | for i in query.includes.filterIt(it != "nativeretweets"): 100 | params.add &"i-{i}=on" 101 | 102 | if query.since.len > 0: 103 | params.add "since=" & query.since 104 | if query.until.len > 0: 105 | params.add "until=" & query.until 106 | if query.near.len > 0: 107 | params.add "near=" & query.near 108 | 109 | if params.len > 0: 110 | result &= params.join("&") 111 | -------------------------------------------------------------------------------- /tests/base.py: -------------------------------------------------------------------------------- 1 | from seleniumbase import BaseCase 2 | 3 | 4 | class Card(object): 5 | def __init__(self, tweet=''): 6 | card = tweet + '.card ' 7 | self.link = card + 'a' 8 | self.title = card + '.card-title' 9 | self.description = card + '.card-description' 10 | self.destination = card + '.card-destination' 11 | self.image = card + '.card-image' 12 | 13 | 14 | class Quote(object): 15 | def __init__(self, tweet=''): 16 | quote = tweet + '.quote ' 17 | namerow = quote + '.fullname-and-username ' 18 | self.link = quote + '.quote-link' 19 | self.fullname = namerow + '.fullname' 20 | self.username = namerow + '.username' 21 | self.text = quote + '.quote-text' 22 | self.media = quote + '.quote-media-container' 23 | self.unavailable = quote + '.quote.unavailable' 24 | 25 | 26 | class Tweet(object): 27 | def __init__(self, tweet=''): 28 | namerow = tweet + '.tweet-header ' 29 | self.fullname = namerow + '.fullname' 30 | self.username = namerow + '.username' 31 | self.date = namerow + '.tweet-date' 32 | self.text = tweet + '.tweet-content.media-body' 33 | self.retweet = tweet + '.retweet-header' 34 | self.reply = tweet + '.replying-to' 35 | 36 | 37 | class Profile(object): 38 | fullname = '.profile-card-fullname' 39 | username = '.profile-card-username' 40 | protected = '.icon-lock' 41 | verified = '.verified-icon' 42 | banner = '.profile-banner' 43 | bio = '.profile-bio' 44 | location = '.profile-location' 45 | website = '.profile-website' 46 | joinDate = '.profile-joindate' 47 | mediaCount = '.photo-rail-header' 48 | 49 | 50 | class Timeline(object): 51 | newest = 'div[class="timeline-item show-more"]' 52 | older = 'div[class="show-more"]' 53 | end = '.timeline-end' 54 | none = '.timeline-none' 55 | protected = '.timeline-protected' 56 | photo_rail = '.photo-rail-grid' 57 | 58 | 59 | class Conversation(object): 60 | main = '.main-tweet' 61 | before = '.before-tweet' 62 | after = '.after-tweet' 63 | replies = '.replies' 64 | thread = '.reply' 65 | tweet = '.timeline-item' 66 | tweet_text = '.tweet-content' 67 | 68 | 69 | class Poll(object): 70 | votes = '.poll-info' 71 | choice = '.poll-meter' 72 | value = 'poll-choice-value' 73 | option = 'poll-choice-option' 74 | leader = 'leader' 75 | 76 | 77 | class Media(object): 78 | container = '.attachments' 79 | row = '.gallery-row' 80 | image = '.still-image' 81 | video = '.gallery-video' 82 | gif = '.gallery-gif' 83 | 84 | 85 | class BaseTestCase(BaseCase): 86 | def setUp(self): 87 | super(BaseTestCase, self).setUp() 88 | 89 | def tearDown(self): 90 | super(BaseTestCase, self).tearDown() 91 | 92 | def open_nitter(self, page=''): 93 | self.open(f'http://localhost:8080/{page}') 94 | 95 | def search_username(self, username): 96 | self.open_nitter() 97 | self.update_text('.search-bar input[type=text]', username) 98 | self.submit('.search-bar form') 99 | 100 | 101 | def get_timeline_tweet(num=1): 102 | return Tweet(f'.timeline > div:nth-child({num}) ') 103 | -------------------------------------------------------------------------------- /scripts/assets/nginx.conf: -------------------------------------------------------------------------------- 1 | # /etc/nginx/nginx.conf 2 | 3 | user nginx; 4 | 5 | pid /tmp/nginx.pid; 6 | 7 | daemon off; 8 | 9 | # Set number of worker processes automatically based on number of CPU cores. 10 | worker_processes auto; 11 | 12 | # Enables the use of JIT for regular expressions to speed-up their processing. 13 | pcre_jit on; 14 | 15 | # Configures default error logger. 16 | error_log /dev/stdout warn; 17 | 18 | # Includes files with directives to load dynamic modules. 19 | include /etc/nginx/modules/*.conf; 20 | 21 | 22 | events { 23 | # The maximum number of simultaneous connections that can be opened by 24 | # a worker process. 25 | worker_connections 1024; 26 | } 27 | 28 | http { 29 | # Includes mapping of file name extensions to MIME types of responses 30 | # and defines the default type. 31 | include /etc/nginx/mime.types; 32 | default_type application/octet-stream; 33 | 34 | # Name servers used to resolve names of upstream servers into addresses. 35 | # It's also needed when using tcpsocket and udpsocket in Lua modules. 36 | #resolver 208.67.222.222 208.67.220.220; 37 | 38 | # Don't tell nginx version to clients. 39 | server_tokens off; 40 | 41 | # Specifies the maximum accepted body size of a client request, as 42 | # indicated by the request header Content-Length. If the stated content 43 | # length is greater than this size, then the client receives the HTTP 44 | # error code 413. Set to 0 to disable. 45 | client_max_body_size 1m; 46 | 47 | # Timeout for keep-alive connections. Server will close connections after 48 | # this time. 49 | keepalive_timeout 65; 50 | 51 | # Sendfile copies data between one FD and other from within the kernel, 52 | # which is more efficient than read() + write(). 53 | sendfile on; 54 | 55 | # Don't buffer data-sends (disable Nagle algorithm). 56 | # Good for sending frequent small bursts of data in real time. 57 | tcp_nodelay on; 58 | 59 | # Causes nginx to attempt to send its HTTP response head in one packet, 60 | # instead of using partial frames. 61 | #tcp_nopush on; 62 | 63 | 64 | # Path of the file with Diffie-Hellman parameters for EDH ciphers. 65 | #ssl_dhparam /etc/ssl/nginx/dh2048.pem; 66 | 67 | # Specifies that our cipher suits should be preferred over client ciphers. 68 | ssl_prefer_server_ciphers on; 69 | 70 | # Enables a shared SSL cache with size that can hold around 8000 sessions. 71 | ssl_session_cache shared:SSL:2m; 72 | 73 | 74 | # Enable gzipping of responses. 75 | #gzip on; 76 | 77 | # Set the Vary HTTP header as defined in the RFC 2616. 78 | gzip_vary on; 79 | 80 | # Enable checking the existence of precompressed files. 81 | #gzip_static on; 82 | 83 | 84 | # Specifies the main log format. 85 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 86 | '$status $body_bytes_sent "$http_referer" ' 87 | '"$http_user_agent" "$http_x_forwarded_for"'; 88 | 89 | # Sets the path, format, and configuration for a buffered log write. 90 | access_log /dev/stdout main; 91 | 92 | 93 | # Includes virtual hosts configs. 94 | include /etc/nginx/conf.d/*.conf; 95 | } -------------------------------------------------------------------------------- /src/nitter.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import asyncdispatch, strformat, logging 3 | from net import Port 4 | from htmlgen import a 5 | from os import getEnv, existsEnv 6 | 7 | import jester 8 | 9 | import types, config, prefs, formatters, redis_cache, http_pool, auth 10 | import views/[general, about] 11 | import routes/[ 12 | preferences, timeline, status, media, search, rss, list, debug, 13 | unsupported, embed, resolver, router_utils] 14 | 15 | import sentry 16 | 17 | const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" 18 | const issuesUrl = "https://github.com/zedeus/nitter/issues" 19 | 20 | if existsEnv("SENTRY_DSN"): 21 | echo "Sentry enabled" 22 | init(getEnv("SENTRY_DSN")) 23 | else: 24 | echo "Sentry disabled" 25 | 26 | let 27 | configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf") 28 | (cfg, fullCfg) = getConfig(configPath) 29 | 30 | accountsPath = getEnv("NITTER_ACCOUNTS_FILE", "./guest_accounts.json") 31 | 32 | initAccountPool(cfg, accountsPath) 33 | 34 | if not cfg.enableDebug: 35 | # Silence Jester's query warning 36 | addHandler(newConsoleLogger()) 37 | setLogFilter(lvlError) 38 | 39 | stdout.write &"Starting Nitter at {getUrlPrefix(cfg)}\n" 40 | stdout.flushFile 41 | 42 | updateDefaultPrefs(fullCfg) 43 | setCacheTimes(cfg) 44 | setHmacKey(cfg.hmacKey) 45 | setProxyEncoding(cfg.base64Media) 46 | setMaxHttpConns(cfg.httpMaxConns) 47 | setHttpProxy(cfg.proxy, cfg.proxyAuth) 48 | initAboutPage(cfg.staticDir) 49 | 50 | waitFor initRedisPool(cfg) 51 | stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n" 52 | stdout.flushFile 53 | 54 | createUnsupportedRouter(cfg) 55 | createResolverRouter(cfg) 56 | createPrefRouter(cfg) 57 | createTimelineRouter(cfg) 58 | createListRouter(cfg) 59 | createStatusRouter(cfg) 60 | createSearchRouter(cfg) 61 | createMediaRouter(cfg) 62 | createEmbedRouter(cfg) 63 | createRssRouter(cfg) 64 | createDebugRouter(cfg) 65 | 66 | settings: 67 | port = Port(cfg.port) 68 | staticDir = cfg.staticDir 69 | bindAddr = cfg.address 70 | reusePort = true 71 | 72 | routes: 73 | get "/": 74 | resp renderMain(renderSearch(), request, cfg, themePrefs()) 75 | 76 | get "/about": 77 | resp renderMain(renderAbout(), request, cfg, themePrefs()) 78 | 79 | get "/explore": 80 | redirect("/about") 81 | 82 | get "/help": 83 | redirect("/about") 84 | 85 | get "/i/redirect": 86 | let url = decodeUrl(@"url") 87 | if url.len == 0: resp Http404 88 | redirect(replaceUrls(url, cookiePrefs())) 89 | 90 | error Http404: 91 | resp Http404, showError("Page not found", cfg) 92 | 93 | error InternalError: 94 | echo error.exc.name, ": ", error.exc.msg 95 | const link = a("open a GitHub issue", href = issuesUrl) 96 | resp Http500, showError( 97 | &"An error occurred, please {link} with the URL you tried to visit.", cfg) 98 | 99 | error BadClientError: 100 | echo error.exc.name, ": ", error.exc.msg 101 | resp Http500, showError("Network error occurred, please try again.", cfg) 102 | 103 | error RateLimitError: 104 | const link = a("another instance", href = instancesUrl) 105 | resp Http429, showError( 106 | &"Instance has been rate limited.
Use {link} or try again later.", cfg) 107 | 108 | extend rss, "" 109 | extend status, "" 110 | extend search, "" 111 | extend timeline, "" 112 | extend media, "" 113 | extend list, "" 114 | extend preferences, "" 115 | extend resolver, "" 116 | extend embed, "" 117 | extend debug, "" 118 | extend unsupported, "" 119 | -------------------------------------------------------------------------------- /src/sass/timeline.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | .timeline-container { 4 | @include panel(100%, 600px); 5 | } 6 | 7 | .timeline { 8 | background-color: var(--bg_panel); 9 | 10 | > div:not(:first-child) { 11 | border-top: 1px solid var(--border_grey); 12 | } 13 | } 14 | 15 | .timeline-header { 16 | width: 100%; 17 | background-color: var(--bg_panel); 18 | text-align: center; 19 | padding: 8px; 20 | display: block; 21 | font-weight: bold; 22 | margin-bottom: 5px; 23 | box-sizing: border-box; 24 | 25 | button { 26 | float: unset; 27 | } 28 | } 29 | 30 | .timeline-banner img { 31 | width: 100%; 32 | } 33 | 34 | .timeline-description { 35 | font-weight: normal; 36 | } 37 | 38 | .tab { 39 | align-items: center; 40 | display: flex; 41 | flex-wrap: wrap; 42 | list-style: none; 43 | margin: 0 0 5px 0; 44 | background-color: var(--bg_panel); 45 | padding: 0; 46 | } 47 | 48 | .tab-item { 49 | flex: 1 1 0; 50 | text-align: center; 51 | margin-top: 0; 52 | 53 | a { 54 | border-bottom: .1rem solid transparent; 55 | color: var(--tab); 56 | display: block; 57 | padding: 8px 0; 58 | text-decoration: none; 59 | font-weight: bold; 60 | 61 | &:hover { 62 | text-decoration: none; 63 | } 64 | 65 | &.active { 66 | border-bottom-color: var(--tab_selected); 67 | color: var(--tab_selected); 68 | } 69 | } 70 | 71 | &.active a { 72 | border-bottom-color: var(--tab_selected); 73 | color: var(--tab_selected); 74 | } 75 | 76 | &.wide { 77 | flex-grow: 1.2; 78 | flex-basis: 50px; 79 | } 80 | } 81 | 82 | .timeline-footer { 83 | background-color: var(--bg_panel); 84 | padding: 6px 0; 85 | } 86 | 87 | .timeline-protected { 88 | text-align: center; 89 | 90 | p { 91 | margin: 8px 0; 92 | } 93 | 94 | h2 { 95 | color: var(--accent); 96 | font-size: 20px; 97 | font-weight: 600; 98 | } 99 | } 100 | 101 | .timeline-none { 102 | color: var(--accent); 103 | font-size: 20px; 104 | font-weight: 600; 105 | text-align: center; 106 | } 107 | 108 | .timeline-end { 109 | background-color: var(--bg_panel); 110 | color: var(--accent); 111 | font-size: 16px; 112 | font-weight: 600; 113 | text-align: center; 114 | } 115 | 116 | .show-more { 117 | background-color: var(--bg_panel); 118 | text-align: center; 119 | padding: .75em 0; 120 | display: block !important; 121 | 122 | a { 123 | background-color: var(--darkest_grey); 124 | display: inline-block; 125 | height: 2em; 126 | padding: 0 2em; 127 | line-height: 2em; 128 | 129 | &:hover { 130 | background-color: var(--darker_grey); 131 | } 132 | } 133 | } 134 | 135 | .top-ref { 136 | background-color: var(--bg_color); 137 | border-top: none !important; 138 | 139 | .icon-down { 140 | font-size: 20px; 141 | display: flex; 142 | justify-content: center; 143 | text-decoration: none; 144 | 145 | &:hover { 146 | color: var(--accent_light); 147 | } 148 | 149 | &::before { 150 | transform: rotate(180deg) translateY(-1px); 151 | } 152 | } 153 | } 154 | 155 | .timeline-item { 156 | overflow-wrap: break-word; 157 | border-left-width: 0; 158 | min-width: 0; 159 | padding: .75em; 160 | display: flex; 161 | position: relative; 162 | } 163 | -------------------------------------------------------------------------------- /src/views/renderutils.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat 3 | import karax/[karaxdsl, vdom, vstyles] 4 | import ".."/[types, utils] 5 | 6 | const smallWebp* = "?name=small&format=webp" 7 | 8 | proc getSmallPic*(url: string): string = 9 | result = url 10 | if "?" notin url and not url.endsWith("placeholder.png"): 11 | result &= smallWebp 12 | result = getPicUrl(result) 13 | 14 | proc icon*(icon: string; text=""; title=""; class=""; href=""): VNode = 15 | var c = "icon-" & icon 16 | if class.len > 0: c = &"{c} {class}" 17 | buildHtml(tdiv(class="icon-container")): 18 | if href.len > 0: 19 | a(class=c, title=title, href=href) 20 | else: 21 | span(class=c, title=title) 22 | 23 | if text.len > 0: 24 | text " " & text 25 | 26 | template verifiedIcon*(user: User): untyped {.dirty.} = 27 | if user.verifiedType != VerifiedType.none: 28 | let lower = ($user.verifiedType).toLowerAscii() 29 | icon "ok", class=(&"verified-icon {lower}"), title=(&"Verified {lower} account") 30 | else: 31 | text "" 32 | 33 | proc linkUser*(user: User, class=""): VNode = 34 | let 35 | isName = "username" notin class 36 | href = "/" & user.username 37 | nameText = if isName: user.fullname 38 | else: "@" & user.username 39 | 40 | buildHtml(a(href=href, class=class, title=nameText)): 41 | text nameText 42 | if isName: 43 | verifiedIcon(user) 44 | if user.protected: 45 | text " " 46 | icon "lock", title="Protected account" 47 | 48 | proc linkText*(text: string; class=""): VNode = 49 | let url = if "http" notin text: https & text else: text 50 | buildHtml(): 51 | a(href=url, class=class): text text 52 | 53 | proc hiddenField*(name, value: string): VNode = 54 | buildHtml(): 55 | input(name=name, style={display: "none"}, value=value) 56 | 57 | proc refererField*(path: string): VNode = 58 | hiddenField("referer", path) 59 | 60 | proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNode = 61 | buildHtml(form(`method`=`method`, action=action, class=class)): 62 | refererField path 63 | button(`type`="submit"): 64 | text text 65 | 66 | proc genCheckbox*(pref, label: string; state: bool): VNode = 67 | buildHtml(label(class="pref-group checkbox-container")): 68 | text label 69 | input(name=pref, `type`="checkbox", checked=state) 70 | span(class="checkbox") 71 | 72 | proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode = 73 | let p = placeholder 74 | buildHtml(tdiv(class=("pref-group pref-input " & class))): 75 | if label.len > 0: 76 | label(`for`=pref): text label 77 | input(name=pref, `type`="text", placeholder=p, value=state, autofocus=(autofocus and state.len == 0)) 78 | 79 | proc genSelect*(pref, label, state: string; options: seq[string]): VNode = 80 | buildHtml(tdiv(class="pref-group pref-input")): 81 | label(`for`=pref): text label 82 | select(name=pref): 83 | for opt in options: 84 | option(value=opt, selected=(opt == state)): 85 | text opt 86 | 87 | proc genDate*(pref, state: string): VNode = 88 | buildHtml(span(class="date-input")): 89 | input(name=pref, `type`="date", value=state) 90 | icon "calendar" 91 | 92 | proc genImg*(url: string; class=""): VNode = 93 | buildHtml(): 94 | img(src=getPicUrl(url), class=class, alt="") 95 | 96 | proc getTabClass*(query: Query; tab: QueryKind): string = 97 | if query.kind == tab: "tab-item active" 98 | else: "tab-item" 99 | 100 | proc getAvatarClass*(prefs: Prefs): string = 101 | if prefs.squareAvatars: "avatar" 102 | else: "avatar round" 103 | -------------------------------------------------------------------------------- /src/experimental/types/unifiedcard.nim: -------------------------------------------------------------------------------- 1 | import std/[options, tables, times] 2 | import jsony 3 | from ../../types import VideoType, VideoVariant, User 4 | 5 | type 6 | Text* = distinct string 7 | 8 | UnifiedCard* = object 9 | componentObjects*: Table[string, Component] 10 | destinationObjects*: Table[string, Destination] 11 | mediaEntities*: Table[string, MediaEntity] 12 | appStoreData*: Table[string, seq[AppStoreData]] 13 | 14 | ComponentType* = enum 15 | details 16 | media 17 | swipeableMedia 18 | buttonGroup 19 | jobDetails 20 | appStoreDetails 21 | twitterListDetails 22 | communityDetails 23 | mediaWithDetailsHorizontal 24 | hidden 25 | unknown 26 | 27 | Component* = object 28 | kind*: ComponentType 29 | data*: ComponentData 30 | 31 | ComponentData* = object 32 | id*: string 33 | appId*: string 34 | mediaId*: string 35 | destination*: string 36 | location*: string 37 | title*: Text 38 | subtitle*: Text 39 | name*: Text 40 | memberCount*: int 41 | mediaList*: seq[MediaItem] 42 | topicDetail*: tuple[title: Text] 43 | profileUser*: User 44 | shortDescriptionText*: string 45 | 46 | MediaItem* = object 47 | id*: string 48 | destination*: string 49 | 50 | Destination* = object 51 | kind*: string 52 | data*: tuple[urlData: UrlData] 53 | 54 | UrlData* = object 55 | url*: string 56 | vanity*: string 57 | 58 | MediaType* = enum 59 | photo, video, model3d 60 | 61 | MediaEntity* = object 62 | kind*: MediaType 63 | mediaUrlHttps*: string 64 | videoInfo*: Option[VideoInfo] 65 | 66 | VideoInfo* = object 67 | durationMillis*: int 68 | variants*: seq[VideoVariant] 69 | 70 | AppType* = enum 71 | androidApp, iPhoneApp, iPadApp 72 | 73 | AppStoreData* = object 74 | kind*: AppType 75 | id*: string 76 | title*: Text 77 | category*: Text 78 | 79 | TypeField = Component | Destination | MediaEntity | AppStoreData 80 | 81 | converter fromText*(text: Text): string = string(text) 82 | 83 | proc renameHook*(v: var TypeField; fieldName: var string) = 84 | if fieldName == "type": 85 | fieldName = "kind" 86 | 87 | proc enumHook*(s: string; v: var ComponentType) = 88 | v = case s 89 | of "details": details 90 | of "media": media 91 | of "swipeable_media": swipeableMedia 92 | of "button_group": buttonGroup 93 | of "job_details": jobDetails 94 | of "app_store_details": appStoreDetails 95 | of "twitter_list_details": twitterListDetails 96 | of "community_details": communityDetails 97 | of "media_with_details_horizontal": mediaWithDetailsHorizontal 98 | of "commerce_drop_details": hidden 99 | else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown 100 | 101 | proc enumHook*(s: string; v: var AppType) = 102 | v = case s 103 | of "android_app": androidApp 104 | of "iphone_app": iPhoneApp 105 | of "ipad_app": iPadApp 106 | else: echo "ERROR: Unknown enum value (AppType): ", s; androidApp 107 | 108 | proc enumHook*(s: string; v: var MediaType) = 109 | v = case s 110 | of "video": video 111 | of "photo": photo 112 | of "model3d": model3d 113 | else: echo "ERROR: Unknown enum value (MediaType): ", s; photo 114 | 115 | proc parseHook*(s: string; i: var int; v: var DateTime) = 116 | var str: string 117 | parseHook(s, i, str) 118 | v = parse(str, "yyyy-MM-dd hh:mm:ss") 119 | 120 | proc parseHook*(s: string; i: var int; v: var Text) = 121 | if s[i] == '"': 122 | var str: string 123 | parseHook(s, i, str) 124 | v = Text(str) 125 | else: 126 | var t: tuple[content: string] 127 | parseHook(s, i, t) 128 | v = Text(t.content) 129 | -------------------------------------------------------------------------------- /src/experimental/parser/unifiedcard.nim: -------------------------------------------------------------------------------- 1 | import std/[options, tables, strutils, strformat, sugar] 2 | import jsony 3 | import user, ../types/unifiedcard 4 | from ../../types import Card, CardKind, Video 5 | from ../../utils import twimg, https 6 | 7 | proc getImageUrl(entity: MediaEntity): string = 8 | entity.mediaUrlHttps.dup(removePrefix(twimg), removePrefix(https)) 9 | 10 | proc parseDestination(id: string; card: UnifiedCard; result: var Card) = 11 | let destination = card.destinationObjects[id].data 12 | result.dest = destination.urlData.vanity 13 | result.url = destination.urlData.url 14 | 15 | proc parseDetails(data: ComponentData; card: UnifiedCard; result: var Card) = 16 | data.destination.parseDestination(card, result) 17 | 18 | result.text = data.title 19 | if result.text.len == 0: 20 | result.text = data.name 21 | 22 | proc parseMediaDetails(data: ComponentData; card: UnifiedCard; result: var Card) = 23 | data.destination.parseDestination(card, result) 24 | 25 | result.kind = summary 26 | result.image = card.mediaEntities[data.mediaId].getImageUrl 27 | result.text = data.topicDetail.title 28 | result.dest = "Topic" 29 | 30 | proc parseJobDetails(data: ComponentData; card: UnifiedCard; result: var Card) = 31 | data.destination.parseDestination(card, result) 32 | 33 | result.kind = CardKind.jobDetails 34 | result.title = data.title 35 | result.text = data.shortDescriptionText 36 | result.dest = &"@{data.profileUser.username} · {data.location}" 37 | 38 | proc parseAppDetails(data: ComponentData; card: UnifiedCard; result: var Card) = 39 | let app = card.appStoreData[data.appId][0] 40 | 41 | case app.kind 42 | of androidApp: 43 | result.url = "http://play.google.com/store/apps/details?id=" & app.id 44 | of iPhoneApp, iPadApp: 45 | result.url = "https://itunes.apple.com/app/id" & app.id 46 | 47 | result.text = app.title 48 | result.dest = app.category 49 | 50 | proc parseListDetails(data: ComponentData; result: var Card) = 51 | result.dest = &"List · {data.memberCount} Members" 52 | 53 | proc parseCommunityDetails(data: ComponentData; result: var Card) = 54 | result.dest = &"Community · {data.memberCount} Members" 55 | 56 | proc parseMedia(component: Component; card: UnifiedCard; result: var Card) = 57 | let mediaId = 58 | if component.kind == swipeableMedia: 59 | component.data.mediaList[0].id 60 | else: 61 | component.data.id 62 | 63 | let rMedia = card.mediaEntities[mediaId] 64 | case rMedia.kind: 65 | of photo: 66 | result.kind = summaryLarge 67 | result.image = rMedia.getImageUrl 68 | of video: 69 | let videoInfo = rMedia.videoInfo.get 70 | result.kind = promoVideo 71 | result.video = some Video( 72 | available: true, 73 | thumb: rMedia.getImageUrl, 74 | durationMs: videoInfo.durationMillis, 75 | variants: videoInfo.variants 76 | ) 77 | of model3d: 78 | result.title = "Unsupported 3D model ad" 79 | 80 | proc parseUnifiedCard*(json: string): Card = 81 | let card = json.fromJson(UnifiedCard) 82 | 83 | for component in card.componentObjects.values: 84 | case component.kind 85 | of details, communityDetails, twitterListDetails: 86 | component.data.parseDetails(card, result) 87 | of appStoreDetails: 88 | component.data.parseAppDetails(card, result) 89 | of mediaWithDetailsHorizontal: 90 | component.data.parseMediaDetails(card, result) 91 | of media, swipeableMedia: 92 | component.parseMedia(card, result) 93 | of buttonGroup: 94 | discard 95 | of ComponentType.jobDetails: 96 | component.data.parseJobDetails(card, result) 97 | of ComponentType.hidden: 98 | result.kind = CardKind.hidden 99 | of ComponentType.unknown: 100 | echo "ERROR: Unknown component type: ", json 101 | 102 | case component.kind 103 | of twitterListDetails: 104 | component.data.parseListDetails(result) 105 | of communityDetails: 106 | component.data.parseCommunityDetails(result) 107 | else: discard 108 | -------------------------------------------------------------------------------- /src/sass/inputs.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | 4 | button { 5 | @include input-colors; 6 | background-color: var(--bg_elements); 7 | color: var(--fg_color); 8 | border: 1px solid var(--accent_border); 9 | padding: 3px 6px; 10 | font-size: 14px; 11 | cursor: pointer; 12 | float: right; 13 | } 14 | 15 | input[type="text"], 16 | input[type="date"], 17 | select { 18 | @include input-colors; 19 | background-color: var(--bg_elements); 20 | padding: 1px 4px; 21 | color: var(--fg_color); 22 | border: 1px solid var(--accent_border); 23 | border-radius: 0; 24 | font-size: 14px; 25 | } 26 | 27 | input[type="text"] { 28 | height: 16px; 29 | } 30 | 31 | select { 32 | height: 20px; 33 | padding: 0 2px; 34 | line-height: 1; 35 | } 36 | 37 | input[type="date"]::-webkit-inner-spin-button { 38 | display: none; 39 | } 40 | 41 | input[type="date"]::-webkit-clear-button { 42 | margin-left: 17px; 43 | filter: grayscale(100%); 44 | filter: hue-rotate(120deg); 45 | } 46 | 47 | input::-webkit-calendar-picker-indicator { 48 | opacity: 0; 49 | } 50 | 51 | input::-webkit-datetime-edit-day-field:focus, 52 | input::-webkit-datetime-edit-month-field:focus, 53 | input::-webkit-datetime-edit-year-field:focus { 54 | background-color: var(--accent); 55 | color: var(--fg_color); 56 | outline: none; 57 | } 58 | 59 | .date-range { 60 | .date-input { 61 | display: inline-block; 62 | position: relative; 63 | } 64 | 65 | .icon-container { 66 | pointer-events: none; 67 | position: absolute; 68 | top: 2px; 69 | right: 5px; 70 | } 71 | 72 | .search-title { 73 | margin: 0 2px; 74 | } 75 | } 76 | 77 | .icon-button button { 78 | color: var(--accent); 79 | text-decoration: none; 80 | background: none; 81 | border: none; 82 | float: none; 83 | padding: unset; 84 | padding-left: 4px; 85 | 86 | &:hover { 87 | color: var(--accent_light); 88 | } 89 | } 90 | 91 | .checkbox { 92 | position: absolute; 93 | top: 1px; 94 | right: 0; 95 | height: 17px; 96 | width: 17px; 97 | background-color: var(--bg_elements); 98 | border: 1px solid var(--accent_border); 99 | 100 | &:after { 101 | content: ""; 102 | position: absolute; 103 | display: none; 104 | } 105 | } 106 | 107 | .checkbox-container { 108 | display: block; 109 | position: relative; 110 | margin-bottom: 5px; 111 | cursor: pointer; 112 | user-select: none; 113 | padding-right: 22px; 114 | 115 | input { 116 | position: absolute; 117 | opacity: 0; 118 | cursor: pointer; 119 | height: 0; 120 | width: 0; 121 | 122 | &:checked ~ .checkbox:after { 123 | display: block; 124 | } 125 | } 126 | 127 | &:hover input ~ .checkbox { 128 | border-color: var(--accent); 129 | } 130 | 131 | &:active input ~ .checkbox { 132 | border-color: var(--accent_light); 133 | } 134 | 135 | .checkbox:after { 136 | left: 2px; 137 | bottom: 0; 138 | font-size: 13px; 139 | font-family: $font_4; 140 | content: '\e803'; 141 | } 142 | } 143 | 144 | .pref-group { 145 | display: inline; 146 | } 147 | 148 | .preferences { 149 | button { 150 | margin: 6px 0 3px 0; 151 | } 152 | 153 | label { 154 | padding-right: 150px; 155 | } 156 | 157 | select { 158 | position: absolute; 159 | top: 0; 160 | right: 0; 161 | display: block; 162 | -moz-appearance: none; 163 | -webkit-appearance: none; 164 | appearance: none; 165 | } 166 | 167 | input[type="text"] { 168 | position: absolute; 169 | right: 0; 170 | max-width: 140px; 171 | } 172 | 173 | .pref-group { 174 | display: block; 175 | } 176 | 177 | .pref-input { 178 | position: relative; 179 | margin-bottom: 6px; 180 | } 181 | 182 | .pref-reset { 183 | float: left; 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/sass/index.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | 3 | @import 'tweet/_base'; 4 | @import 'profile/_base'; 5 | @import 'general'; 6 | @import 'navbar'; 7 | @import 'inputs'; 8 | @import 'timeline'; 9 | @import 'search'; 10 | 11 | body { 12 | // colors 13 | --bg_color: #{$bg_color}; 14 | --fg_color: #{$fg_color}; 15 | --fg_faded: #{$fg_faded}; 16 | --fg_dark: #{$fg_dark}; 17 | --fg_nav: #{$fg_nav}; 18 | 19 | --bg_panel: #{$bg_panel}; 20 | --bg_elements: #{$bg_elements}; 21 | --bg_overlays: #{$bg_overlays}; 22 | --bg_hover: #{$bg_hover}; 23 | 24 | --grey: #{$grey}; 25 | --dark_grey: #{$dark_grey}; 26 | --darker_grey: #{$darker_grey}; 27 | --darkest_grey: #{$darkest_grey}; 28 | --border_grey: #{$border_grey}; 29 | 30 | --accent: #{$accent}; 31 | --accent_light: #{$accent_light}; 32 | --accent_dark: #{$accent_dark}; 33 | --accent_border: #{$accent_border}; 34 | 35 | --play_button: #{$play_button}; 36 | --play_button_hover: #{$play_button_hover}; 37 | 38 | --more_replies_dots: #{$more_replies_dots}; 39 | --error_red: #{$error_red}; 40 | 41 | --verified_blue: #{$verified_blue}; 42 | --verified_business: #{$verified_business}; 43 | --verified_government: #{$verified_government}; 44 | --icon_text: #{$icon_text}; 45 | 46 | --tab: #{$fg_color}; 47 | --tab_selected: #{$accent}; 48 | 49 | --profile_stat: #{$fg_color}; 50 | 51 | background-color: var(--bg_color); 52 | color: var(--fg_color); 53 | font-family: $font_0, $font_1, $font_2, $font_3; 54 | font-size: 14px; 55 | line-height: 1.3; 56 | margin: 0; 57 | } 58 | 59 | * { 60 | outline: unset; 61 | margin: 0; 62 | text-decoration: none; 63 | } 64 | 65 | h1 { 66 | display: inline; 67 | } 68 | 69 | h2, h3 { 70 | font-weight: normal; 71 | } 72 | 73 | p { 74 | margin: 14px 0; 75 | } 76 | 77 | a { 78 | color: var(--accent); 79 | 80 | &:hover { 81 | text-decoration: underline; 82 | } 83 | } 84 | 85 | fieldset { 86 | border: 0; 87 | padding: 0; 88 | margin-top: -0.6em; 89 | } 90 | 91 | legend { 92 | width: 100%; 93 | padding: .6em 0 .3em 0; 94 | border: 0; 95 | font-size: 16px; 96 | font-weight: 600; 97 | border-bottom: 1px solid var(--border_grey); 98 | margin-bottom: 8px; 99 | } 100 | 101 | .preferences .note { 102 | border-top: 1px solid var(--border_grey); 103 | border-bottom: 1px solid var(--border_grey); 104 | padding: 6px 0 8px 0; 105 | margin-bottom: 8px; 106 | margin-top: 16px; 107 | } 108 | 109 | ul { 110 | padding-left: 1.3em; 111 | } 112 | 113 | .container { 114 | display: flex; 115 | flex-wrap: wrap; 116 | box-sizing: border-box; 117 | padding-top: 50px; 118 | margin: auto; 119 | min-height: 100vh; 120 | } 121 | 122 | .icon-container { 123 | display: inline; 124 | } 125 | 126 | .overlay-panel { 127 | max-width: 600px; 128 | width: 100%; 129 | margin: 0 auto; 130 | margin-top: 10px; 131 | background-color: var(--bg_overlays); 132 | padding: 10px 15px; 133 | align-self: start; 134 | 135 | ul { 136 | margin-bottom: 14px; 137 | } 138 | 139 | p { 140 | word-break: break-word; 141 | } 142 | } 143 | 144 | .verified-icon { 145 | color: var(--icon_text); 146 | border-radius: 50%; 147 | flex-shrink: 0; 148 | margin: 2px 0 3px 3px; 149 | padding-top: 3px; 150 | height: 11px; 151 | width: 14px; 152 | font-size: 8px; 153 | display: inline-block; 154 | text-align: center; 155 | vertical-align: middle; 156 | 157 | &.blue { 158 | background-color: var(--verified_blue); 159 | } 160 | 161 | &.business { 162 | color: var(--bg_panel); 163 | background-color: var(--verified_business); 164 | } 165 | 166 | &.government { 167 | color: var(--bg_panel); 168 | background-color: var(--verified_government); 169 | } 170 | } 171 | 172 | @media(max-width: 600px) { 173 | .preferences-container { 174 | max-width: 95vw; 175 | } 176 | 177 | .nav-item, .nav-item .icon-container { 178 | font-size: 16px; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /tests/test_card.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Card, Conversation 2 | from parameterized import parameterized 3 | 4 | 5 | card = [ 6 | ['nim_lang/status/1136652293510717440', 7 | 'Version 0.20.0 released', 8 | 'We are very proud to announce Nim version 0.20. This is a massive release, both literally and figuratively. It contains more than 1,000 commits and it marks our release candidate for version 1.0!', 9 | 'nim-lang.org', True], 10 | 11 | ['voidtarget/status/1094632512926605312', 12 | 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)', 13 | 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim', 14 | 'gist.github.com', True], 15 | 16 | ['nim_lang/status/1082989146040340480', 17 | 'Nim in 2018: A short recap', 18 | 'There were several big news in the Nim world in 2018 – two new major releases, partnership with Status, and much more. But let us go chronologically.', 19 | 'nim-lang.org', True] 20 | ] 21 | 22 | no_thumb = [ 23 | ['FluentAI/status/1116417904831029248', 24 | 'LinkedIn', 25 | 'This link will take you to a page that’s not on LinkedIn', 26 | 'lnkd.in'], 27 | 28 | ['Thom_Wolf/status/1122466524860702729', 29 | 'facebookresearch/fairseq', 30 | 'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in Python.', 31 | 'github.com'], 32 | 33 | ['brent_p/status/1088857328680488961', 34 | 'Hts Nim Sugar', 35 | 'hts-nim is a library that allows one to use htslib via the nim programming language. Nim is a garbage-collected language that compiles to C and often has similar performance. I have become very...', 36 | 'brentp.github.io'], 37 | 38 | ['voidtarget/status/1133028231672582145', 39 | 'sinkingsugar/nimqt-example', 40 | 'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.', 41 | 'github.com'] 42 | ] 43 | 44 | playable = [ 45 | ['nim_lang/status/1118234460904919042', 46 | 'Nim development blog 2019-03', 47 | 'Arne (aka Krux02)* debugging: * improved nim-gdb, $ works, framefilter * alias for --debugger:native: -g* bugs: * forwarding of .pure. * sizeof union* fe...', 48 | 'youtube.com'], 49 | 50 | ['nim_lang/status/1121090879823986688', 51 | 'Nim - First natively compiled language w/ hot code-reloading at...', 52 | '#nim #c++ #ACCUConfNim is a statically typed systems and applications programming language which offers perhaps some of the most powerful metaprogramming cap...', 53 | 'youtube.com'] 54 | ] 55 | 56 | class CardTest(BaseTestCase): 57 | @parameterized.expand(card) 58 | def test_card(self, tweet, title, description, destination, large): 59 | self.open_nitter(tweet) 60 | c = Card(Conversation.main + " ") 61 | self.assert_text(title, c.title) 62 | self.assert_text(destination, c.destination) 63 | self.assertIn('/pic/', self.get_image_url(c.image + ' img')) 64 | if len(description) > 0: 65 | self.assert_text(description, c.description) 66 | if large: 67 | self.assert_element_visible('.card.large') 68 | else: 69 | self.assert_element_not_visible('.card.large') 70 | 71 | @parameterized.expand(no_thumb) 72 | def test_card_no_thumb(self, tweet, title, description, destination): 73 | self.open_nitter(tweet) 74 | c = Card(Conversation.main + " ") 75 | self.assert_text(title, c.title) 76 | self.assert_text(destination, c.destination) 77 | if len(description) > 0: 78 | self.assert_text(description, c.description) 79 | 80 | @parameterized.expand(playable) 81 | def test_card_playable(self, tweet, title, description, destination): 82 | self.open_nitter(tweet) 83 | c = Card(Conversation.main + " ") 84 | self.assert_text(title, c.title) 85 | self.assert_text(destination, c.destination) 86 | self.assertIn('/pic/', self.get_image_url(c.image + ' img')) 87 | self.assert_element_visible('.card-overlay') 88 | if len(description) > 0: 89 | self.assert_text(description, c.description) 90 | -------------------------------------------------------------------------------- /tests/test_tweet_media.py: -------------------------------------------------------------------------------- 1 | from base import BaseTestCase, Poll, Media 2 | from parameterized import parameterized 3 | from selenium.webdriver.common.by import By 4 | 5 | poll = [ 6 | ['nim_lang/status/1064219801499955200', 'Style insensitivity', '91', 1, [ 7 | ('47%', 'Yay'), ('53%', 'Nay') 8 | ]], 9 | 10 | ['polls/status/1031986180622049281', 'What Tree Is Coolest?', '3,322', 1, [ 11 | ('30%', 'Oak'), ('42%', 'Bonsai'), ('5%', 'Hemlock'), ('23%', 'Apple') 12 | ]] 13 | ] 14 | 15 | image = [ 16 | ['mobile_test/status/519364660823207936', 'BzUnaDFCUAAmrjs'], 17 | #['mobile_test_2/status/324619691039543297', 'BIFH45vCUAAQecj'] 18 | ] 19 | 20 | gif = [ 21 | ['elonmusk/status/1141367104702038016', 'D9bzUqoUcAAfUgf'], 22 | ['Proj_Borealis/status/1136595194621677568', 'D8X_PJAXUAAavPT'] 23 | ] 24 | 25 | video_m3u8 = [ 26 | ['d0m96/status/1078373829917974528', '9q1-v9w8-ft3awgD.jpg'], 27 | ['SpaceX/status/1138474014152712192', 'ocJJj2uu4n1kyD2Y.jpg'] 28 | ] 29 | 30 | gallery = [ 31 | # ['mobile_test/status/451108446603980803', [ 32 | # ['BkKovdrCUAAEz79', 'BkKovdcCEAAfoBO'] 33 | # ]], 34 | 35 | # ['mobile_test/status/471539824713691137', [ 36 | # ['Bos--KNIQAAA7Li', 'Bos--FAIAAAWpah'], 37 | # ['Bos--IqIQAAav23'] 38 | # ]], 39 | 40 | ['mobile_test/status/469530783384743936', [ 41 | ['BoQbwJAIUAA0QCY', 'BoQbwN1IMAAuTiP'], 42 | ['BoQbwarIAAAlaE-', 'BoQbwh_IEAA27ef'] 43 | ]] 44 | ] 45 | 46 | 47 | class MediaTest(BaseTestCase): 48 | @parameterized.expand(poll) 49 | def test_poll(self, tweet, text, votes, leader, choices): 50 | self.open_nitter(tweet) 51 | self.assert_text(text, '.main-tweet') 52 | self.assert_text(votes, Poll.votes) 53 | 54 | poll_choices = self.find_elements(Poll.choice) 55 | for i, (v, o) in enumerate(choices): 56 | choice = poll_choices[i] 57 | value = choice.find_element(By.CLASS_NAME, Poll.value) 58 | option = choice.find_element(By.CLASS_NAME, Poll.option) 59 | choice_class = choice.get_attribute('class') 60 | 61 | self.assert_equal(v, value.text) 62 | self.assert_equal(o, option.text) 63 | 64 | if i == leader: 65 | self.assertIn(Poll.leader, choice_class) 66 | else: 67 | self.assertNotIn(Poll.leader, choice_class) 68 | 69 | @parameterized.expand(image) 70 | def test_image(self, tweet, url): 71 | self.open_nitter(tweet) 72 | self.assert_element_visible(Media.container) 73 | self.assert_element_visible(Media.image) 74 | 75 | image_url = self.get_image_url(Media.image + ' img') 76 | self.assertIn(url, image_url) 77 | 78 | @parameterized.expand(gif) 79 | def test_gif(self, tweet, gif_id): 80 | self.open_nitter(tweet) 81 | self.assert_element_visible(Media.container) 82 | self.assert_element_visible(Media.gif) 83 | 84 | url = self.get_attribute('source', 'src') 85 | thumb = self.get_attribute('video', 'poster') 86 | self.assertIn(gif_id + '.mp4', url) 87 | self.assertIn(gif_id + '.jpg', thumb) 88 | 89 | @parameterized.expand(video_m3u8) 90 | def test_video_m3u8(self, tweet, thumb): 91 | # no url because video playback isn't supported yet 92 | self.open_nitter(tweet) 93 | self.assert_element_visible(Media.container) 94 | self.assert_element_visible(Media.video) 95 | 96 | video_thumb = self.get_attribute(Media.video + ' img', 'src') 97 | self.assertIn(thumb, video_thumb) 98 | 99 | @parameterized.expand(gallery) 100 | def test_gallery(self, tweet, rows): 101 | self.open_nitter(tweet) 102 | self.assert_element_visible(Media.container) 103 | self.assert_element_visible(Media.row) 104 | self.assert_element_visible(Media.image) 105 | 106 | gallery_rows = self.find_elements(Media.row) 107 | self.assert_equal(len(rows), len(gallery_rows)) 108 | 109 | for i, row in enumerate(gallery_rows): 110 | images = row.find_elements(By.CSS_SELECTOR, 'img') 111 | self.assert_equal(len(rows[i]), len(images)) 112 | for j, image in enumerate(images): 113 | self.assertIn(rows[i][j], image.get_attribute('src')) 114 | -------------------------------------------------------------------------------- /src/routes/media.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import uri, strutils, httpclient, os, hashes, base64, re 3 | import asynchttpserver, asyncstreams, asyncfile, asyncnet 4 | 5 | import jester 6 | 7 | import router_utils 8 | import ".."/[types, formatters, utils] 9 | 10 | export asynchttpserver, asyncstreams, asyncfile, asyncnet 11 | export httpclient, os, strutils, asyncstreams, base64, re 12 | 13 | const 14 | m3u8Mime* = "application/vnd.apple.mpegurl" 15 | maxAge* = "max-age=604800" 16 | 17 | proc safeFetch*(url: string): Future[string] {.async.} = 18 | let client = newAsyncHttpClient() 19 | try: result = await client.getContent(url) 20 | except: discard 21 | finally: client.close() 22 | 23 | template respond*(req: asynchttpserver.Request; headers) = 24 | var msg = "HTTP/1.1 200 OK\c\L" 25 | for k, v in headers: 26 | msg.add(k & ": " & v & "\c\L") 27 | 28 | msg.add "\c\L" 29 | yield req.client.send(msg) 30 | 31 | proc proxyMedia*(req: jester.Request; url: string): Future[HttpCode] {.async.} = 32 | result = Http200 33 | let 34 | request = req.getNativeReq() 35 | client = newAsyncHttpClient() 36 | 37 | try: 38 | let res = await client.get(url) 39 | if res.status != "200 OK": 40 | echo "[media] Proxying failed, status: $1, url: $2" % [res.status, url] 41 | return Http404 42 | 43 | let hashed = $hash(url) 44 | if request.headers.getOrDefault("If-None-Match") == hashed: 45 | return Http304 46 | 47 | let contentLength = 48 | if res.headers.hasKey("content-length"): 49 | res.headers["content-length", 0] 50 | else: 51 | "" 52 | 53 | let headers = newHttpHeaders({ 54 | "Content-Type": res.headers["content-type", 0], 55 | "Content-Length": contentLength, 56 | "Cache-Control": maxAge, 57 | "ETag": hashed 58 | }) 59 | 60 | respond(request, headers) 61 | 62 | var (hasValue, data) = (true, "") 63 | while hasValue: 64 | (hasValue, data) = await res.bodyStream.read() 65 | if hasValue: 66 | await request.client.send(data) 67 | data.setLen 0 68 | except HttpRequestError, ProtocolError, OSError: 69 | echo "[media] Proxying exception, error: $1, url: $2" % [getCurrentExceptionMsg(), url] 70 | result = Http404 71 | finally: 72 | client.close() 73 | 74 | template check*(code): untyped = 75 | if code != Http200: 76 | resp code 77 | else: 78 | enableRawMode() 79 | break route 80 | 81 | proc decoded*(req: jester.Request; index: int): string = 82 | let 83 | based = req.matches[0].len > 1 84 | encoded = req.matches[index] 85 | if based: decode(encoded) 86 | else: decodeUrl(encoded) 87 | 88 | proc createMediaRouter*(cfg: Config) = 89 | router media: 90 | get "/pic/?": 91 | resp Http404 92 | 93 | get re"^\/pic\/orig\/(enc)?\/?(.+)": 94 | var url = decoded(request, 1) 95 | if "twimg.com" notin url: 96 | url.insert(twimg) 97 | if not url.startsWith(https): 98 | url.insert(https) 99 | url.add("?name=orig") 100 | 101 | let uri = parseUri(url) 102 | cond isTwitterUrl(uri) == true 103 | 104 | let code = await proxyMedia(request, url) 105 | check code 106 | 107 | get re"^\/pic\/(enc)?\/?(.+)": 108 | var url = decoded(request, 1) 109 | if "twimg.com" notin url: 110 | url.insert(twimg) 111 | if not url.startsWith(https): 112 | url.insert(https) 113 | 114 | let uri = parseUri(url) 115 | cond isTwitterUrl(uri) == true 116 | 117 | let code = await proxyMedia(request, url) 118 | check code 119 | 120 | get re"^\/video\/(enc)?\/?(.+)\/(.+)$": 121 | let url = decoded(request, 2) 122 | cond "http" in url 123 | 124 | if getHmac(url) != request.matches[1]: 125 | resp showError("Failed to verify signature", cfg) 126 | 127 | if ".mp4" in url or ".ts" in url or ".m4s" in url: 128 | let code = await proxyMedia(request, url) 129 | check code 130 | 131 | var content: string 132 | if ".vmap" in url: 133 | let m3u8 = getM3u8Url(await safeFetch(url)) 134 | if m3u8.len > 0: 135 | content = await safeFetch(url) 136 | else: 137 | resp Http404 138 | 139 | if ".m3u8" in url: 140 | let vid = await safeFetch(url) 141 | content = proxifyVideo(vid, cookiePref(proxyVideos)) 142 | 143 | resp content, m3u8Mime 144 | -------------------------------------------------------------------------------- /src/views/preferences.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import tables, macros, strutils 3 | import karax/[karaxdsl, vdom] 4 | 5 | import renderutils 6 | import ../types, ../prefs_impl 7 | 8 | from os import getEnv, existsEnv 9 | 10 | macro renderPrefs*(): untyped = 11 | result = nnkCall.newTree( 12 | ident("buildHtml"), ident("tdiv"), nnkStmtList.newTree()) 13 | 14 | for header, options in prefList: 15 | result[2].add nnkCall.newTree( 16 | ident("legend"), 17 | nnkStmtList.newTree( 18 | nnkCommand.newTree(ident("text"), newLit(header)))) 19 | 20 | for pref in options: 21 | let procName = ident("gen" & capitalizeAscii($pref.kind)) 22 | let state = nnkDotExpr.newTree(ident("prefs"), ident(pref.name)) 23 | var stmt = nnkStmtList.newTree( 24 | nnkCall.newTree(procName, newLit(pref.name), newLit(pref.label), state)) 25 | 26 | case pref.kind 27 | of checkbox: discard 28 | of input: stmt[0].add newLit(pref.placeholder) 29 | of select: 30 | if pref.name == "theme": 31 | stmt[0].add ident("themes") 32 | else: 33 | stmt[0].add newLit(pref.options) 34 | 35 | result[2].add stmt 36 | 37 | let openNitterRssUrlJs = """ 38 | javascript:(function() { 39 | const url = window.location.href; 40 | if (!url.startsWith("https://x.com")) { 41 | alert("This is not a Twitter page"); 42 | return 43 | } 44 | const rssUrl = `HTTP_OR_S://HOSTNAME${url.slice("https://x.com".length)}/rssRSS_KEY`; 45 | window.open(rssUrl, '_blank').focus(); 46 | })(); 47 | """ 48 | 49 | let subscribeNitterRssToMinifluxJs = """ 50 | javascript:(function() { 51 | const url = window.location.href; 52 | if (!url.startsWith("https://x.com")) { 53 | alert("This is not a Twitter page"); 54 | return 55 | } 56 | const rssUrl = `HTTP_OR_S://HOSTNAME${url.slice("https://x.com".length)}/rssRSS_KEY`; 57 | const minifluxUrl = `https://MINIFLUX_HOSTNAME/bookmarklet?uri=${encodeURIComponent(rssUrl)}`; 58 | window.open(minifluxUrl, '_blank').focus(); 59 | })(); 60 | """ 61 | 62 | let subscribeNitterRssToInoreaderJs = """ 63 | javascript:(function() { 64 | const url = window.location.href; 65 | if (!url.startsWith("https://x.com")) { 66 | alert("This is not a Twitter page"); 67 | return 68 | } 69 | const rssUrl = `HTTP_OR_S://HOSTNAME${url.slice("https://x.com".length)}/rssRSS_KEY`; 70 | const inoreaderUrl = `https://www.inoreader.com/search/feeds/${encodeURIComponent(rssUrl)}`; 71 | window.open(inoreaderUrl, '_blank').focus(); 72 | })(); 73 | """ 74 | 75 | proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]; hostname: string; useHttps: bool): VNode = 76 | buildHtml(tdiv(class="overlay-panel")): 77 | fieldset(class="preferences"): 78 | form(`method`="post", action="/saveprefs", autocomplete="off"): 79 | refererField path 80 | 81 | renderPrefs() 82 | 83 | legend: 84 | text "Bookmarklets (drag them to bookmark bar)" 85 | 86 | if prefs.minifluxHostname.len > 0: 87 | a(href=subscribeNitterRssToMinifluxJs 88 | .replace("MINIFLUX_HOSTNAME", prefs.minifluxHostname) 89 | .replace("HTTP_OR_S", if useHttps: "https" else: "http") 90 | .replace("HOSTNAME", hostname) 91 | .replace("RSS_KEY", if existsEnv("INSTANCE_RSS_PASSWORD"): "?key=" & getEnv("INSTANCE_RSS_PASSWORD") else: "")): 92 | text "Subscribe Nitter RSS to Miniflux" 93 | 94 | br() 95 | 96 | a(href=subscribeNitterRssToInoreaderJs 97 | .replace("HTTP_OR_S", if useHttps: "https" else: "http") 98 | .replace("HOSTNAME", hostname) 99 | .replace("RSS_KEY", if existsEnv("INSTANCE_RSS_PASSWORD"): "?key=" & getEnv("INSTANCE_RSS_PASSWORD") else: "")): 100 | text "Subscribe Nitter RSS to Inoreader" 101 | 102 | br() 103 | 104 | a(href=openNitterRssUrlJs 105 | .replace("HTTP_OR_S", if useHttps: "https" else: "http") 106 | .replace("HOSTNAME", hostname) 107 | .replace("RSS_KEY", if existsEnv("INSTANCE_RSS_PASSWORD"): "?key=" & getEnv("INSTANCE_RSS_PASSWORD") else: "")): 108 | text "Open Nitter RSS URL" 109 | 110 | h4(class="note"): 111 | text "Preferences are stored client-side using cookies without any personal information." 112 | 113 | button(`type`="submit", class="pref-submit"): 114 | text "Save preferences" 115 | 116 | buttonReferer "/resetprefs", "Reset preferences", path, class="pref-reset" 117 | -------------------------------------------------------------------------------- /src/views/profile.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat 3 | import karax/[karaxdsl, vdom, vstyles] 4 | 5 | import renderutils, search 6 | import ".."/[types, utils, formatters] 7 | 8 | proc renderStat(num: int; class: string; text=""): VNode = 9 | let t = if text.len > 0: text else: class 10 | buildHtml(li(class=class)): 11 | span(class="profile-stat-header"): text capitalizeAscii(t) 12 | span(class="profile-stat-num"): 13 | text insertSep($num, ',') 14 | 15 | proc renderUserCard*(user: User; prefs: Prefs): VNode = 16 | buildHtml(tdiv(class="profile-card")): 17 | tdiv(class="profile-card-info"): 18 | let 19 | url = getPicUrl(user.getUserPic()) 20 | size = 21 | if prefs.autoplayGifs and user.userPic.endsWith("gif"): "" 22 | else: "_400x400" 23 | 24 | a(class="profile-card-avatar", href=url, target="_blank"): 25 | genImg(user.getUserPic(size)) 26 | 27 | tdiv(class="profile-card-tabs-name"): 28 | linkUser(user, class="profile-card-fullname") 29 | linkUser(user, class="profile-card-username") 30 | 31 | tdiv(class="profile-card-extra"): 32 | if user.bio.len > 0: 33 | tdiv(class="profile-bio"): 34 | p(dir="auto"): 35 | verbatim replaceUrls(user.bio, prefs) 36 | 37 | if user.location.len > 0: 38 | tdiv(class="profile-location"): 39 | span: icon "location" 40 | let (place, url) = getLocation(user) 41 | if url.len > 1: 42 | a(href=url): text place 43 | elif "://" in place: 44 | a(href=place): text place 45 | else: 46 | span: text place 47 | 48 | if user.website.len > 0: 49 | tdiv(class="profile-website"): 50 | span: 51 | let url = replaceUrls(user.website, prefs) 52 | icon "link" 53 | a(href=url): text url.shortLink 54 | 55 | tdiv(class="profile-joindate"): 56 | span(title=getJoinDateFull(user)): 57 | icon "calendar", getJoinDate(user) 58 | 59 | tdiv(class="profile-card-extra-links"): 60 | ul(class="profile-statlist"): 61 | renderStat(user.tweets, "posts", text="Tweets") 62 | renderStat(user.following, "following") 63 | renderStat(user.followers, "followers") 64 | renderStat(user.likes, "likes") 65 | 66 | proc renderPhotoRail(profile: Profile): VNode = 67 | let count = insertSep($profile.user.media, ',') 68 | buildHtml(tdiv(class="photo-rail-card")): 69 | tdiv(class="photo-rail-header"): 70 | a(href=(&"/{profile.user.username}/media")): 71 | icon "picture", count & " Photos and videos" 72 | 73 | input(id="photo-rail-grid-toggle", `type`="checkbox") 74 | label(`for`="photo-rail-grid-toggle", class="photo-rail-header-mobile"): 75 | icon "picture", count & " Photos and videos" 76 | icon "down" 77 | 78 | tdiv(class="photo-rail-grid"): 79 | for i, photo in profile.photoRail: 80 | if i == 16: break 81 | let photoSuffix = 82 | if "format" in photo.url or "placeholder" in photo.url: "" 83 | else: ":thumb" 84 | a(href=(&"/{profile.user.username}/status/{photo.tweetId}#m")): 85 | genImg(photo.url & photoSuffix) 86 | 87 | proc renderBanner(banner: string): VNode = 88 | buildHtml(): 89 | if banner.len == 0: 90 | a() 91 | elif banner.startsWith('#'): 92 | a(style={backgroundColor: banner}) 93 | else: 94 | a(href=getPicUrl(banner), target="_blank"): genImg(banner) 95 | 96 | proc renderProtected(username: string): VNode = 97 | buildHtml(tdiv(class="timeline-container")): 98 | tdiv(class="timeline-header timeline-protected"): 99 | h2: text "This account's tweets are protected." 100 | p: text &"Only confirmed followers have access to @{username}'s tweets." 101 | 102 | proc renderProfile*(profile: var Profile; prefs: Prefs; path: string): VNode = 103 | profile.tweets.query.fromUser = @[profile.user.username] 104 | 105 | buildHtml(tdiv(class="profile-tabs")): 106 | if not prefs.hideBanner: 107 | tdiv(class="profile-banner"): 108 | renderBanner(profile.user.banner) 109 | 110 | let sticky = if prefs.stickyProfile: " sticky" else: "" 111 | tdiv(class=("profile-tab" & sticky)): 112 | renderUserCard(profile.user, prefs) 113 | if profile.photoRail.len > 0: 114 | renderPhotoRail(profile) 115 | 116 | if profile.user.protected: 117 | renderProtected(profile.user.username) 118 | else: 119 | renderTweetSearch(profile.tweets, prefs, path, profile.pinned) 120 | -------------------------------------------------------------------------------- /.github/workflows/build-publish-docker.yml: -------------------------------------------------------------------------------- 1 | # borrowed from 2 | # https://github.com/PrivacyDevel/nitter/blob/master/.github/workflows/build-publish-docker.yml 3 | name: Build and Publish Docker 4 | 5 | on: 6 | push: 7 | branches: ["master"] 8 | paths-ignore: ["README.md"] 9 | pull_request: 10 | branches: ["master"] 11 | paths-ignore: ["README.md"] 12 | 13 | env: 14 | IMAGE_NAME: ${{ github.repository }} 15 | 16 | permissions: 17 | contents: read 18 | packages: write 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout repository 25 | uses: actions/checkout@v4 26 | - name: Log in to GHCR 27 | uses: docker/login-action@v3 28 | with: 29 | registry: ghcr.io 30 | username: ${{ github.actor }} 31 | password: ${{ secrets.GITHUB_TOKEN }} 32 | - name: Extract Docker metadata 33 | id: meta 34 | uses: docker/metadata-action@v5 35 | with: 36 | images: | 37 | ghcr.io/${{ env.IMAGE_NAME }} 38 | - name: Build and push Docker image 39 | id: build-and-push 40 | uses: docker/build-push-action@v5 41 | with: 42 | context: . 43 | file: original.Dockerfile 44 | push: ${{ github.event_name != 'pull_request' }} 45 | tags: ${{ steps.meta.outputs.tags }} 46 | labels: ${{ steps.meta.outputs.labels }} 47 | platforms: linux/amd64 48 | - name: Export digest 49 | if: github.event_name != 'pull_request' 50 | run: | 51 | mkdir -p /tmp/digests 52 | digest="${{ steps.build-and-push.outputs.digest }}" 53 | touch "/tmp/digests/${digest#sha256:}" 54 | - name: Upload digest 55 | uses: actions/upload-artifact@v4 56 | if: github.event_name != 'pull_request' 57 | with: 58 | name: digests-amd64 59 | path: /tmp/digests/* 60 | if-no-files-found: error 61 | retention-days: 1 62 | 63 | build-arm: 64 | runs-on: buildjet-2vcpu-ubuntu-2204-arm 65 | steps: 66 | - name: Checkout repository 67 | uses: actions/checkout@v4 68 | - name: Log in to GHCR 69 | uses: docker/login-action@v3 70 | with: 71 | registry: ghcr.io 72 | username: ${{ github.actor }} 73 | password: ${{ secrets.GITHUB_TOKEN }} 74 | - name: Extract Docker metadata 75 | id: meta 76 | uses: docker/metadata-action@v5 77 | with: 78 | images: | 79 | ghcr.io/${{ env.IMAGE_NAME }} 80 | - name: Build and push Docker image 81 | id: build-and-push 82 | uses: docker/build-push-action@v5 83 | with: 84 | context: . 85 | file: original.Dockerfile 86 | push: ${{ github.event_name != 'pull_request' }} 87 | tags: ${{ steps.meta.outputs.tags }} 88 | labels: ${{ steps.meta.outputs.labels }} 89 | platforms: linux/arm64 90 | - name: Export digest 91 | if: github.event_name != 'pull_request' 92 | run: | 93 | mkdir -p /tmp/digests 94 | digest="${{ steps.build-and-push.outputs.digest }}" 95 | touch "/tmp/digests/${digest#sha256:}" 96 | - name: Upload digest 97 | uses: actions/upload-artifact@v4 98 | if: github.event_name != 'pull_request' 99 | with: 100 | name: digests-arm64 101 | path: /tmp/digests/* 102 | if-no-files-found: error 103 | retention-days: 1 104 | 105 | merge: 106 | runs-on: ubuntu-latest 107 | needs: 108 | - build 109 | - build-arm 110 | if: github.event_name != 'pull_request' 111 | steps: 112 | - name: Download digests 113 | uses: actions/download-artifact@v4 114 | with: 115 | path: /tmp/digests 116 | pattern: digests-* 117 | merge-multiple: true 118 | - name: Set up Docker Buildx 119 | uses: docker/setup-buildx-action@v3 120 | - name: Docker meta 121 | id: meta 122 | uses: docker/metadata-action@v5 123 | with: 124 | images: ghcr.io/${{ github.repository }} 125 | - name: Log in to the Github Container registry 126 | uses: docker/login-action@v3 127 | with: 128 | registry: ghcr.io 129 | username: ${{ github.actor }} 130 | password: ${{ secrets.GITHUB_TOKEN }} 131 | - name: Create manifest list and push 132 | working-directory: /tmp/digests 133 | run: | 134 | docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ 135 | $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *) 136 | -------------------------------------------------------------------------------- /src/views/search.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat, sequtils, unicode, tables, options 3 | import karax/[karaxdsl, vdom] 4 | 5 | import renderutils, timeline 6 | import ".."/[types, query] 7 | 8 | const toggles = { 9 | "nativeretweets": "Retweets", 10 | "media": "Media", 11 | "videos": "Videos", 12 | "news": "News", 13 | "verified": "Verified", 14 | "native_video": "Native videos", 15 | "replies": "Replies", 16 | "links": "Links", 17 | "images": "Images", 18 | "safe": "Safe", 19 | "quote": "Quotes", 20 | "pro_video": "Pro videos" 21 | }.toOrderedTable 22 | 23 | proc renderSearch*(): VNode = 24 | buildHtml(tdiv(class="panel-container")): 25 | tdiv(class="search-bar"): 26 | form(`method`="get", action="/search", autocomplete="off"): 27 | hiddenField("f", "users") 28 | input(`type`="text", name="q", autofocus="", 29 | placeholder="Enter username...", dir="auto") 30 | button(`type`="submit"): icon "search" 31 | 32 | proc renderProfileTabs*(query: Query; username: string): VNode = 33 | let link = "/" & username 34 | buildHtml(ul(class="tab")): 35 | li(class=query.getTabClass(posts)): 36 | a(href=link): text "Tweets" 37 | li(class=(query.getTabClass(replies) & " wide")): 38 | a(href=(link & "/with_replies")): text "Tweets & Replies" 39 | li(class=query.getTabClass(media)): 40 | a(href=(link & "/media")): text "Media" 41 | li(class=query.getTabClass(tweets)): 42 | a(href=(link & "/search")): text "Search" 43 | 44 | proc renderSearchTabs*(query: Query): VNode = 45 | var q = query 46 | buildHtml(ul(class="tab")): 47 | li(class=query.getTabClass(tweets)): 48 | q.kind = tweets 49 | a(href=("?" & genQueryUrl(q))): text "Tweets" 50 | li(class=query.getTabClass(users)): 51 | q.kind = users 52 | a(href=("?" & genQueryUrl(q))): text "Users" 53 | 54 | proc isPanelOpen(q: Query): bool = 55 | q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or 56 | @[q.near, q.until, q.since].anyIt(it.len > 0)) 57 | 58 | proc renderSearchPanel*(query: Query): VNode = 59 | let user = query.fromUser.join(",") 60 | let action = if user.len > 0: &"/{user}/search" else: "/search" 61 | buildHtml(form(`method`="get", action=action, 62 | class="search-field", autocomplete="off")): 63 | hiddenField("f", "tweets") 64 | genInput("q", "", query.text, "Enter search...", class="pref-inline") 65 | button(`type`="submit"): icon "search" 66 | 67 | input(id="search-panel-toggle", `type`="checkbox", checked=isPanelOpen(query)) 68 | label(`for`="search-panel-toggle"): icon "down" 69 | 70 | tdiv(class="search-panel"): 71 | for f in @["filter", "exclude"]: 72 | span(class="search-title"): text capitalize(f) 73 | tdiv(class="search-toggles"): 74 | for k, v in toggles: 75 | let state = 76 | if f == "filter": k in query.filters 77 | else: k in query.excludes 78 | genCheckbox(&"{f[0]}-{k}", v, state) 79 | 80 | tdiv(class="search-row"): 81 | tdiv: 82 | span(class="search-title"): text "Time range" 83 | tdiv(class="date-range"): 84 | genDate("since", query.since) 85 | span(class="search-title"): text "-" 86 | genDate("until", query.until) 87 | tdiv: 88 | span(class="search-title"): text "Near" 89 | genInput("near", "", query.near, "Location...", autofocus=false) 90 | 91 | proc renderTweetSearch*(results: Timeline; prefs: Prefs; path: string; 92 | pinned=none(Tweet)): VNode = 93 | let query = results.query 94 | buildHtml(tdiv(class="timeline-container")): 95 | if query.fromUser.len > 1: 96 | tdiv(class="timeline-header"): 97 | text query.fromUser.join(" | ") 98 | 99 | if query.fromUser.len > 0: 100 | renderProfileTabs(query, query.fromUser.join(",")) 101 | 102 | if query.fromUser.len == 0 or query.kind == tweets: 103 | tdiv(class="timeline-header"): 104 | renderSearchPanel(query) 105 | 106 | if query.fromUser.len == 0: 107 | renderSearchTabs(query) 108 | 109 | renderTimelineTweets(results, prefs, path, pinned) 110 | 111 | proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode = 112 | buildHtml(tdiv(class="timeline-container")): 113 | tdiv(class="timeline-header"): 114 | form(`method`="get", action="/search", class="search-field", autocomplete="off"): 115 | hiddenField("f", "users") 116 | genInput("q", "", results.query.text, "Enter username...", class="pref-inline") 117 | button(`type`="submit"): icon "search" 118 | 119 | renderSearchTabs(results.query) 120 | renderTimelineUsers(results, prefs) 121 | -------------------------------------------------------------------------------- /.github/workflows/build-publish-self-contained-docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish self-contained Docker image 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | if: github.event_name == 'push' 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v4 18 | - name: Log in to the Github Container registry 19 | uses: docker/login-action@v3 20 | with: 21 | registry: ghcr.io 22 | username: ${{ github.actor }} 23 | password: ${{ secrets.GITHUB_TOKEN }} 24 | - name: Build and push 25 | id: build 26 | uses: docker/build-push-action@v5 27 | with: 28 | context: . 29 | file: Dockerfile 30 | push: true 31 | tags: ghcr.io/${{ github.repository_owner }}/nitter-self-contained:latest 32 | platforms: linux/amd64 33 | - name: Export digest 34 | run: | 35 | mkdir -p /tmp/digests 36 | digest="${{ steps.build.outputs.digest }}" 37 | touch "/tmp/digests/${digest#sha256:}" 38 | - name: Upload digest 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: digests-amd64 42 | path: /tmp/digests/* 43 | if-no-files-found: error 44 | retention-days: 1 45 | 46 | build-arm: 47 | runs-on: buildjet-2vcpu-ubuntu-2204-arm 48 | if: github.event_name == 'push' 49 | steps: 50 | - name: Checkout code 51 | uses: actions/checkout@v4 52 | - name: Log in to the Github Container registry 53 | uses: docker/login-action@v3 54 | with: 55 | registry: ghcr.io 56 | username: ${{ github.actor }} 57 | password: ${{ secrets.GITHUB_TOKEN }} 58 | - name: Build and push 59 | id: build 60 | uses: docker/build-push-action@v5 61 | with: 62 | context: . 63 | file: Dockerfile 64 | push: true 65 | tags: ghcr.io/${{ github.repository_owner }}/nitter-self-contained:latest 66 | platforms: linux/arm64 67 | - name: Export digest 68 | run: | 69 | mkdir -p /tmp/digests 70 | digest="${{ steps.build.outputs.digest }}" 71 | touch "/tmp/digests/${digest#sha256:}" 72 | - name: Upload digest 73 | uses: actions/upload-artifact@v4 74 | with: 75 | name: digests-arm64 76 | path: /tmp/digests/* 77 | if-no-files-found: error 78 | retention-days: 1 79 | 80 | merge: 81 | runs-on: ubuntu-latest 82 | needs: 83 | - build 84 | - build-arm 85 | if: github.event_name == 'push' 86 | steps: 87 | - name: Download digests 88 | uses: actions/download-artifact@v4 89 | with: 90 | path: /tmp/digests 91 | pattern: digests-* 92 | merge-multiple: true 93 | - name: Set up Docker Buildx 94 | uses: docker/setup-buildx-action@v3 95 | - name: Log in to the Github Container registry 96 | uses: docker/login-action@v3 97 | with: 98 | registry: ghcr.io 99 | username: ${{ github.actor }} 100 | password: ${{ secrets.GITHUB_TOKEN }} 101 | - name: Create manifest list and push 102 | working-directory: /tmp/digests 103 | run: | 104 | docker buildx imagetools create -t ghcr.io/${{ github.repository_owner }}/nitter-self-contained:latest $(printf 'ghcr.io/${{ github.repository_owner }}/nitter-self-contained@sha256:%s ' *) 105 | 106 | deploy-fly: 107 | runs-on: ubuntu-latest 108 | needs: 109 | - merge 110 | if: github.event_name == 'push' 111 | steps: 112 | - uses: actions/checkout@v4 113 | - name: Copy and update fly.toml 114 | run: | 115 | cp fly.example.toml fly.toml 116 | sed -i "s/app = 'nitter'/app = '${{ env.FLY_APP_NAME }}'/" fly.toml 117 | env: 118 | FLY_APP_NAME: ${{ secrets.FLY_APP_NAME }} 119 | - uses: superfly/flyctl-actions/setup-flyctl@master 120 | - run: flyctl deploy --remote-only 121 | env: 122 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 123 | 124 | deploy-vps: 125 | runs-on: ubuntu-latest 126 | needs: 127 | - merge 128 | if: github.event_name == 'push' 129 | steps: 130 | - name: Setup Tailscale 131 | uses: tailscale/github-action@v2 132 | with: 133 | oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }} 134 | oauth-secret: ${{ secrets.TS_OAUTH_SECRET }} 135 | tags: tag:ops 136 | - name: Pull and restart latest container 137 | run: | 138 | ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "nixos@gibraltar" "cd /home/nixos/galerie && docker compose pull nitter && docker compose up nitter -d" 139 | -------------------------------------------------------------------------------- /src/views/timeline.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import strutils, strformat, algorithm, uri, options 3 | import karax/[karaxdsl, vdom] 4 | 5 | import ".."/[types, query, formatters] 6 | import tweet, renderutils 7 | 8 | proc getQuery(query: Query): string = 9 | if query.kind != posts: 10 | result = genQueryUrl(query) 11 | if result.len > 0: 12 | result &= "&" 13 | 14 | proc renderToTop*(focus="#"): VNode = 15 | buildHtml(tdiv(class="top-ref")): 16 | icon "down", href=focus 17 | 18 | proc renderNewer*(query: Query; path: string; focus=""): VNode = 19 | let 20 | q = genQueryUrl(query) 21 | url = if q.len > 0: "?" & q else: "" 22 | p = if focus.len > 0: path.replace("#m", focus) else: path 23 | buildHtml(tdiv(class="timeline-item show-more")): 24 | a(href=(p & url)): 25 | text "Load newest" 26 | 27 | proc renderMore*(query: Query; cursor: string; focus=""): VNode = 28 | buildHtml(tdiv(class="show-more")): 29 | a(href=(&"?{getQuery(query)}cursor={encodeUrl(cursor, usePlus=false)}{focus}")): 30 | text "Load more" 31 | 32 | proc renderNoMore(): VNode = 33 | buildHtml(tdiv(class="timeline-footer")): 34 | h2(class="timeline-end"): 35 | text "No more items" 36 | 37 | proc renderNoneFound(): VNode = 38 | buildHtml(tdiv(class="timeline-header")): 39 | h2(class="timeline-none"): 40 | text "No items found" 41 | 42 | proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = 43 | buildHtml(tdiv(class="thread-line")): 44 | let sortedThread = thread.sortedByIt(it.id) 45 | for i, tweet in sortedThread: 46 | # thread has a gap, display "more replies" link 47 | if i > 0 and tweet.replyId != sortedThread[i - 1].id: 48 | tdiv(class="timeline-item thread more-replies-thread"): 49 | tdiv(class="more-replies"): 50 | a(class="more-replies-text", href=getLink(tweet)): 51 | text "more replies" 52 | 53 | let show = i == thread.high and sortedThread[0].id != tweet.threadId 54 | let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: "" 55 | renderTweet(tweet, prefs, path, class=(header & "thread"), 56 | index=i, last=(i == thread.high), showThread=show) 57 | 58 | proc renderUser(user: User; prefs: Prefs): VNode = 59 | buildHtml(tdiv(class="timeline-item")): 60 | a(class="tweet-link", href=("/" & user.username)) 61 | tdiv(class="tweet-body profile-result"): 62 | tdiv(class="tweet-header"): 63 | a(class="tweet-avatar", href=("/" & user.username)): 64 | genImg(user.getUserPic("_bigger"), class=prefs.getAvatarClass) 65 | 66 | tdiv(class="tweet-name-row"): 67 | tdiv(class="fullname-and-username"): 68 | linkUser(user, class="fullname") 69 | linkUser(user, class="username") 70 | 71 | tdiv(class="tweet-content media-body", dir="auto"): 72 | verbatim replaceUrls(user.bio, prefs) 73 | 74 | proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode = 75 | buildHtml(tdiv(class="timeline")): 76 | if not results.beginning: 77 | renderNewer(results.query, path) 78 | 79 | if results.content.len > 0: 80 | for user in results.content: 81 | renderUser(user, prefs) 82 | if results.bottom.len > 0: 83 | renderMore(results.query, results.bottom) 84 | renderToTop() 85 | elif results.beginning: 86 | renderNoneFound() 87 | else: 88 | renderNoMore() 89 | 90 | proc renderTimelineTweets*(results: Timeline; prefs: Prefs; path: string; 91 | pinned=none(Tweet)): VNode = 92 | buildHtml(tdiv(class="timeline")): 93 | if not results.beginning: 94 | renderNewer(results.query, parseUri(path).path) 95 | 96 | if not prefs.hidePins and pinned.isSome: 97 | let tweet = get pinned 98 | renderTweet(tweet, prefs, path, showThread=tweet.hasThread) 99 | 100 | if results.content.len == 0: 101 | if not results.beginning: 102 | renderNoMore() 103 | else: 104 | renderNoneFound() 105 | else: 106 | var retweets: seq[int64] 107 | 108 | for thread in results.content: 109 | if thread.len == 1: 110 | let 111 | tweet = thread[0] 112 | retweetId = if tweet.retweet.isSome: get(tweet.retweet).id else: 0 113 | 114 | if retweetId in retweets or tweet.id in retweets or 115 | tweet.pinned and prefs.hidePins: 116 | continue 117 | 118 | var hasThread = tweet.hasThread 119 | if retweetId != 0 and tweet.retweet.isSome: 120 | retweets &= retweetId 121 | hasThread = get(tweet.retweet).hasThread 122 | renderTweet(tweet, prefs, path, showThread=hasThread) 123 | else: 124 | renderThread(thread, prefs, path) 125 | 126 | if results.bottom.len > 0: 127 | renderMore(results.query, results.bottom) 128 | renderToTop() 129 | -------------------------------------------------------------------------------- /src/apiutils.nim: -------------------------------------------------------------------------------- 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 5 | import experimental/types/common 6 | 7 | import sentry 8 | 9 | const 10 | rlRemaining = "x-rate-limit-remaining" 11 | rlReset = "x-rate-limit-reset" 12 | 13 | var pool: HttpPool 14 | 15 | proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string = 16 | let 17 | encodedUrl = url.replace(",", "%2C").replace("+", "%20") 18 | params = OAuth1Parameters( 19 | consumerKey: consumerKey, 20 | signatureMethod: "HMAC-SHA1", 21 | timestamp: $int(round(epochTime())), 22 | nonce: "0", 23 | isIncludeVersionToHeader: true, 24 | token: oauthToken 25 | ) 26 | signature = getSignature(HttpGet, encodedUrl, "", params, consumerSecret, oauthTokenSecret) 27 | 28 | params.signature = percentEncode(signature) 29 | 30 | return getOauth1RequestHeader(params)["authorization"] 31 | 32 | proc genHeaders*(url, oauthToken, oauthTokenSecret: string): HttpHeaders = 33 | let header = getOauthHeader(url, oauthToken, oauthTokenSecret) 34 | 35 | result = newHttpHeaders({ 36 | "connection": "keep-alive", 37 | "authorization": header, 38 | "content-type": "application/json", 39 | "x-twitter-active-user": "yes", 40 | "authority": "api.twitter.com", 41 | "accept-encoding": "gzip", 42 | "accept-language": "en-US,en;q=0.9", 43 | "accept": "*/*", 44 | "DNT": "1" 45 | }) 46 | 47 | template fetchImpl(result, fetchBody) {.dirty.} = 48 | once: 49 | pool = HttpPool() 50 | 51 | var account = await getGuestAccount(api) 52 | if account.oauthToken.len == 0: 53 | echo "[accounts] Empty oauth token, account: ", account.id 54 | raise rateLimitError() 55 | 56 | try: 57 | var resp: AsyncResponse 58 | pool.use(genHeaders($url, account.oauthToken, account.oauthSecret)): 59 | template getContent = 60 | resp = await c.get($url) 61 | result = await resp.body 62 | 63 | getContent() 64 | 65 | if resp.status == $Http503: 66 | badClient = true 67 | raise newException(BadClientError, "Bad client") 68 | 69 | if resp.headers.hasKey(rlRemaining): 70 | let 71 | remaining = parseInt(resp.headers[rlRemaining]) 72 | reset = parseInt(resp.headers[rlReset]) 73 | account.setRateLimit(api, remaining, reset) 74 | 75 | if result.len > 0: 76 | if resp.headers.getOrDefault("content-encoding") == "gzip": 77 | result = uncompress(result, dfGzip) 78 | 79 | if result.startsWith("{\"errors"): 80 | let errors = result.fromJson(Errors) 81 | if errors in {expiredToken, badToken}: 82 | echo "fetch error: ", errors 83 | invalidate(account) 84 | raise rateLimitError() 85 | elif errors in {rateLimited}: 86 | # rate limit hit, resets after 24 hours 87 | setLimited(account, api) 88 | raise rateLimitError() 89 | elif result.startsWith("429 Too Many Requests"): 90 | echo "[accounts] 429 error, API: ", api, ", account: ", account.id 91 | account.apis[api].remaining = 0 92 | # rate limit hit, resets after the 15 minute window 93 | raise rateLimitError() 94 | 95 | fetchBody 96 | 97 | if resp.status == $Http400: 98 | raise newException(InternalError, $url) 99 | except InternalError as e: 100 | raise e 101 | except BadClientError as e: 102 | raise e 103 | except OSError as e: 104 | raise e 105 | except Exception as e: 106 | let id = if account.isNil: "null" else: $account.id 107 | echo "error: ", e.name, ", msg: ", e.msg, ", accountId: ", id, ", url: ", url 108 | raise rateLimitError() 109 | finally: 110 | release(account) 111 | 112 | template retry(bod) = 113 | try: 114 | bod 115 | except RateLimitError as e: 116 | let currentTime = now().format("yyyy-MM-dd HH:mm:ss") 117 | echo currentTime, " - [accounts] Rate limited, retrying ", api, " request..." 118 | captureException(e) 119 | bod 120 | 121 | proc fetch*(url: Uri; api: Api): Future[JsonNode] {.async.} = 122 | retry: 123 | var body: string 124 | fetchImpl body: 125 | if body.startsWith('{') or body.startsWith('['): 126 | result = parseJson(body) 127 | else: 128 | echo resp.status, ": ", body, " --- url: ", url 129 | result = newJNull() 130 | 131 | let error = result.getError 132 | if error in {expiredToken, badToken}: 133 | echo "fetchBody error: ", error 134 | invalidate(account) 135 | raise rateLimitError() 136 | 137 | proc fetchRaw*(url: Uri; api: Api): Future[string] {.async.} = 138 | retry: 139 | fetchImpl result: 140 | if not (result.startsWith('{') or result.startsWith('[')): 141 | echo resp.status, ": ", result, " --- url: ", url 142 | result.setLen(0) 143 | -------------------------------------------------------------------------------- /src/sass/tweet/_base.scss: -------------------------------------------------------------------------------- 1 | @import '_variables'; 2 | @import '_mixins'; 3 | @import 'thread'; 4 | @import 'media'; 5 | @import 'video'; 6 | @import 'embed'; 7 | @import 'card'; 8 | @import 'poll'; 9 | @import 'quote'; 10 | 11 | .tweet-body { 12 | flex: 1; 13 | min-width: 0; 14 | margin-left: 58px; 15 | pointer-events: none; 16 | z-index: 1; 17 | } 18 | 19 | .tweet-content { 20 | font-family: $font_3; 21 | line-height: 1.3em; 22 | pointer-events: all; 23 | display: inline; 24 | } 25 | 26 | .tweet-bidi { 27 | display: block !important; 28 | } 29 | 30 | .tweet-header { 31 | padding: 0; 32 | vertical-align: bottom; 33 | flex-basis: 100%; 34 | margin-bottom: .2em; 35 | 36 | a { 37 | display: inline-block; 38 | word-break: break-all; 39 | max-width: 100%; 40 | pointer-events: all; 41 | } 42 | } 43 | 44 | .tweet-name-row { 45 | padding: 0; 46 | display: flex; 47 | justify-content: space-between; 48 | } 49 | 50 | .fullname-and-username { 51 | display: flex; 52 | min-width: 0; 53 | } 54 | 55 | .fullname { 56 | @include ellipsis; 57 | flex-shrink: 2; 58 | max-width: 80%; 59 | font-size: 14px; 60 | font-weight: 700; 61 | color: var(--fg_color); 62 | } 63 | 64 | .username { 65 | @include ellipsis; 66 | min-width: 1.6em; 67 | margin-left: .4em; 68 | word-wrap: normal; 69 | } 70 | 71 | .tweet-date { 72 | display: flex; 73 | flex-shrink: 0; 74 | margin-left: 4px; 75 | } 76 | 77 | .tweet-date a, .username, .show-more a { 78 | color: var(--fg_dark); 79 | } 80 | 81 | .tweet-published { 82 | margin: 0; 83 | margin-top: 5px; 84 | color: var(--grey); 85 | pointer-events: all; 86 | } 87 | 88 | .tweet-avatar { 89 | display: contents !important; 90 | 91 | img { 92 | float: left; 93 | margin-top: 3px; 94 | margin-left: -58px; 95 | width: 48px; 96 | height: 48px; 97 | } 98 | } 99 | 100 | .avatar { 101 | &.round { 102 | border-radius: 50%; 103 | -webkit-user-select: none; 104 | } 105 | 106 | &.mini { 107 | position: unset; 108 | margin-right: 5px; 109 | margin-top: -1px; 110 | width: 20px; 111 | height: 20px; 112 | } 113 | } 114 | 115 | .tweet-embed { 116 | display: flex; 117 | flex-direction: column; 118 | justify-content: center; 119 | height: 100%; 120 | background-color: var(--bg_panel); 121 | 122 | .tweet-content { 123 | font-size: 18px; 124 | } 125 | 126 | .tweet-body { 127 | display: flex; 128 | flex-direction: column; 129 | max-height: calc(100vh - 0.75em * 2); 130 | } 131 | 132 | .card-image img { 133 | height: auto; 134 | } 135 | 136 | .avatar { 137 | position: absolute; 138 | } 139 | } 140 | 141 | .attribution { 142 | display: flex; 143 | pointer-events: all; 144 | margin: 5px 0; 145 | 146 | strong { 147 | color: var(--fg_color); 148 | } 149 | } 150 | 151 | .media-tag-block { 152 | padding-top: 5px; 153 | pointer-events: all; 154 | color: var(--fg_faded); 155 | 156 | .icon-container { 157 | padding-right: 2px; 158 | } 159 | 160 | .media-tag, .icon-container { 161 | color: var(--fg_faded); 162 | } 163 | } 164 | 165 | .timeline-container .media-tag-block { 166 | font-size: 13px; 167 | } 168 | 169 | .tweet-geo { 170 | color: var(--fg_faded); 171 | } 172 | 173 | .replying-to { 174 | color: var(--fg_faded); 175 | margin: -2px 0 4px; 176 | 177 | a { 178 | pointer-events: all; 179 | } 180 | } 181 | 182 | .retweet-header, .pinned, .tweet-stats { 183 | align-content: center; 184 | color: var(--grey); 185 | display: flex; 186 | flex-shrink: 0; 187 | flex-wrap: wrap; 188 | font-size: 14px; 189 | font-weight: 600; 190 | line-height: 22px; 191 | 192 | span { 193 | @include ellipsis; 194 | } 195 | } 196 | 197 | .retweet-header { 198 | margin-top: -5px !important; 199 | } 200 | 201 | .tweet-stats { 202 | margin-bottom: -3px; 203 | -webkit-user-select: none; 204 | } 205 | 206 | .tweet-stat { 207 | padding-top: 5px; 208 | min-width: 1em; 209 | margin-right: 0.8em; 210 | } 211 | 212 | .show-thread { 213 | display: block; 214 | pointer-events: all; 215 | padding-top: 2px; 216 | } 217 | 218 | .unavailable-box { 219 | width: 100%; 220 | height: 100%; 221 | padding: 12px; 222 | border: solid 1px var(--dark_grey); 223 | box-sizing: border-box; 224 | border-radius: 10px; 225 | background-color: var(--bg_color); 226 | z-index: 2; 227 | } 228 | 229 | .tweet-link { 230 | height: 100%; 231 | width: 100%; 232 | left: 0; 233 | top: 0; 234 | position: absolute; 235 | -webkit-user-select: none; 236 | 237 | &:hover { 238 | background-color: var(--bg_hover); 239 | } 240 | } 241 | -------------------------------------------------------------------------------- /src/routes/timeline.nim: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: AGPL-3.0-only 2 | import asyncdispatch, strutils, sequtils, uri, options, times 3 | import jester, karax/vdom 4 | 5 | import router_utils 6 | import ".."/[types, redis_cache, formatters, query, api] 7 | import ../views/[general, profile, timeline, status, search] 8 | 9 | export vdom 10 | export uri, sequtils 11 | export router_utils 12 | export redis_cache, formatters, query, api 13 | export profile, timeline, status 14 | 15 | proc getQuery*(request: Request; tab, name: string): Query = 16 | case tab 17 | of "with_replies": getReplyQuery(name) 18 | of "media": getMediaQuery(name) 19 | of "search": initQuery(params(request), name=name) 20 | else: Query(fromUser: @[name]) 21 | 22 | template skipIf[T](cond: bool; default; body: Future[T]): Future[T] = 23 | if cond: 24 | let fut = newFuture[T]() 25 | fut.complete(default) 26 | fut 27 | else: 28 | body 29 | 30 | proc fetchProfile*(after: string; query: Query; skipRail=false; 31 | skipPinned=false): Future[Profile] {.async.} = 32 | let 33 | name = query.fromUser[0] 34 | userId = await getUserId(name) 35 | 36 | if userId.len == 0: 37 | return Profile(user: User(username: name)) 38 | elif userId == "suspended": 39 | return Profile(user: User(username: name, suspended: true)) 40 | 41 | # temporary fix to prevent errors from people browsing 42 | # timelines during/immediately after deployment 43 | var after = after 44 | if query.kind in {posts, replies} and after.startsWith("scroll"): 45 | after.setLen 0 46 | 47 | let 48 | rail = 49 | skipIf(skipRail or query.kind == media, @[]): 50 | getCachedPhotoRail(name) 51 | 52 | user = getCachedUser(name) 53 | 54 | result = 55 | case query.kind 56 | of posts: await getGraphUserTweets(userId, TimelineKind.tweets, after) 57 | of replies: await getGraphUserTweets(userId, TimelineKind.replies, after) 58 | of media: await getGraphUserTweets(userId, TimelineKind.media, after) 59 | else: Profile(tweets: await getGraphTweetSearch(query, after)) 60 | 61 | result.user = await user 62 | result.photoRail = await rail 63 | 64 | result.tweets.query = query 65 | 66 | proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs; 67 | rss, after: string): Future[string] {.async.} = 68 | if query.fromUser.len != 1: 69 | let 70 | timeline = await getGraphTweetSearch(query, after) 71 | html = renderTweetSearch(timeline, prefs, getPath()) 72 | return renderMain(html, request, cfg, prefs, "Multi", rss=rss) 73 | 74 | var profile = await fetchProfile(after, query, skipPinned=prefs.hidePins) 75 | template u: untyped = profile.user 76 | 77 | if u.suspended: 78 | return showError(getSuspended(u.username), cfg) 79 | 80 | if profile.user.id.len == 0: return 81 | 82 | let pHtml = renderProfile(profile, prefs, getPath()) 83 | result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u), 84 | rss=rss, images = @[u.getUserPic("_400x400")], 85 | banner=u.banner) 86 | 87 | template respTimeline*(timeline: typed) = 88 | let t = timeline 89 | if t.len == 0: 90 | resp Http404, showError("User \"" & @"name" & "\" not found", cfg) 91 | resp t 92 | 93 | template respUserId*() = 94 | cond @"user_id".len > 0 95 | let username = await getCachedUsername(@"user_id") 96 | if username.len > 0: 97 | redirect("/" & username) 98 | else: 99 | resp Http404, showError("User not found", cfg) 100 | 101 | proc createTimelineRouter*(cfg: Config) = 102 | router timeline: 103 | get "/i/user/@user_id": 104 | respUserId() 105 | 106 | get "/intent/user": 107 | respUserId() 108 | 109 | get "/@name/?@tab?/?": 110 | cond '.' notin @"name" 111 | cond @"name" notin ["pic", "gif", "video", "search", "settings", "login", "intent", "i"] 112 | cond @"tab" in ["with_replies", "media", "search", ""] 113 | let 114 | prefs = cookiePrefs() 115 | after = getCursor() 116 | names = getNames(@"name") 117 | 118 | var query = request.getQuery(@"tab", @"name") 119 | if names.len != 1: 120 | query.fromUser = names 121 | 122 | # used for the infinite scroll feature 123 | if @"scroll".len > 0: 124 | if query.fromUser.len != 1: 125 | var timeline = await getGraphTweetSearch(query, after) 126 | if timeline.content.len == 0: resp Http404 127 | timeline.beginning = true 128 | resp $renderTweetSearch(timeline, prefs, getPath()) 129 | else: 130 | var profile = await fetchProfile(after, query, skipRail=true) 131 | if profile.tweets.content.len == 0: resp Http404 132 | profile.tweets.beginning = true 133 | resp $renderTimelineTweets(profile.tweets, prefs, getPath()) 134 | 135 | let rss = 136 | if @"tab".len == 0: 137 | "/$1/rss" % @"name" 138 | elif @"tab" == "search": 139 | "/$1/search/rss?$2" % [@"name", genQueryUrl(query)] 140 | else: 141 | "/$1/$2/rss" % [@"name", @"tab"] 142 | 143 | respTimeline(await showTimeline(request, query, cfg, prefs, rss, after)) 144 | -------------------------------------------------------------------------------- /scripts/gen_nitter_conf.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | 5 | HOSTNAME_PLZ_CHANGE = "[HOSTNAME_PLZ_CHANGE]" 6 | TITLE_PLZ_CHANGE = "[TITLE_PLZ_CHANGE]" 7 | PORT_PLZ_CHANGE = "[PORT_PLZ_CHANGE]" 8 | HTTPS_PLZ_CHANGE = "[HTTPS_PLZ_CHANGE]" 9 | REDIS_HOST_PLZ_CHANGE = "[REDIS_HOST_PLZ_CHANGE]" 10 | REDIS_PORT_PLZ_CHANGE = "[REDIS_PORT_PLZ_CHANGE]" 11 | REDIS_PASSWORD_PLZ_CHANGE = "[REDIS_PASSWORD_PLZ_CHANGE]" 12 | BASE64_MEDIA_PLZ_CHANGE = "[BASE64_MEDIA_PLZ_CHANGE]" 13 | THEME_PLZ_CHANGE = "[THEME_PLZ_CHANGE]" 14 | INFINITE_SCROLL_PLZ_CHANGE = "[INFINITE_SCROLL_PLZ_CHANGE]" 15 | ENABLE_DEBUG_PLZ_CHANGE = "[ENABLE_DEBUG_PLZ_CHANGE]" 16 | RSS_MIUNTES_PLZ_CHANGE = "[RSS_MIUNTES_PLZ_CHANGE]" 17 | 18 | TEMPLATE = """[Server] 19 | hostname = "[HOSTNAME_PLZ_CHANGE]" # for generating links, change this to your own domain/ip 20 | title = "[TITLE_PLZ_CHANGE]" 21 | address = "0.0.0.0" 22 | port = [PORT_PLZ_CHANGE] 23 | https = [HTTPS_PLZ_CHANGE] # disable to enable cookies when not using https 24 | httpMaxConnections = 100 25 | staticDir = "./public" 26 | 27 | [Cache] 28 | listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) 29 | rssMinutes = [RSS_MIUNTES_PLZ_CHANGE] # how long to cache rss queries 30 | redisHost = "[REDIS_HOST_PLZ_CHANGE]" # Change to "nitter-redis" if using docker-compose 31 | redisPort = [REDIS_PORT_PLZ_CHANGE] 32 | redisPassword = "[REDIS_PASSWORD_PLZ_CHANGE]" 33 | redisConnections = 20 # minimum open connections in pool 34 | redisMaxConnections = 30 35 | # new connections are opened when none are available, but if the pool size 36 | # goes above this, they're closed when released. don't worry about this unless 37 | # you receive tons of requests per second 38 | 39 | [Config] 40 | hmacKey = "secretkey" # random key for cryptographic signing of video urls 41 | base64Media = [BASE64_MEDIA_PLZ_CHANGE] # use base64 encoding for proxied media urls 42 | enableRSS = true # set this to false to disable RSS feeds 43 | enableDebug = [ENABLE_DEBUG_PLZ_CHANGE] # enable request logs and debug endpoints (/.accounts) 44 | proxy = "" # http/https url, SOCKS proxies are not supported 45 | proxyAuth = "" 46 | tokenCount = 10 47 | # minimum amount of usable tokens. tokens are used to authorize API requests, 48 | # but they expire after ~1 hour, and have a limit of 500 requests per endpoint. 49 | # the limits reset every 15 minutes, and the pool is filled up so there's 50 | # always at least `tokenCount` usable tokens. only increase this if you receive 51 | # major bursts all the time and don't have a rate limiting setup via e.g. nginx 52 | 53 | # Change default preferences here, see src/prefs_impl.nim for a complete list 54 | [Preferences] 55 | theme = "[THEME_PLZ_CHANGE]" 56 | replaceTwitter = "" 57 | replaceYouTube = "" 58 | replaceReddit = "" 59 | proxyVideos = false 60 | hlsPlayback = true 61 | infiniteScroll = [INFINITE_SCROLL_PLZ_CHANGE] 62 | """ 63 | 64 | 65 | def getenv_treat_empty_string_as_none(key: str, default: str) -> str: 66 | value = os.getenv(key) 67 | if not value: 68 | return default 69 | return value 70 | 71 | 72 | def main() -> str: 73 | # port 74 | port = getenv_treat_empty_string_as_none("INSTANCE_PORT", "8080") 75 | 76 | # hostname 77 | hostname = f"localhost:{port}" 78 | if os.getenv("FLY_APP_NAME"): 79 | hostname = f"{os.getenv('FLY_APP_NAME')}.fly.dev" 80 | elif os.getenv("INSTANCE_HOSTNAME"): 81 | hostname = os.getenv("INSTANCE_HOSTNAME") 82 | 83 | # redis connection info 84 | redis_host = getenv_treat_empty_string_as_none("REDIS_HOST", "localhost") 85 | redis_port = getenv_treat_empty_string_as_none("REDIS_PORT", "6379") 86 | redis_password = getenv_treat_empty_string_as_none("REDIS_PASSWORD", "") 87 | 88 | # other customizations 89 | https = "true" if os.getenv("INSTANCE_HTTPS") == "1" else "false" 90 | base64_media = "true" if os.getenv("INSTANCE_BASE64_MEDIA") == "1" else "false" 91 | title = getenv_treat_empty_string_as_none("INSTANCE_TITLE", "My Nitter instance") 92 | theme = getenv_treat_empty_string_as_none("INSTANCE_THEME", "Nitter") 93 | infinite_scroll = "true" if os.getenv("INSTANCE_INFINITE_SCROLL") == "1" else "false" 94 | enable_debug = "true" if os.getenv("INSTANCE_ENABLE_DEBUG") == "1" else "false" 95 | rss_minutes = getenv_treat_empty_string_as_none("INSTANCE_RSS_MINUTES", "10") 96 | 97 | return TEMPLATE \ 98 | .replace(HOSTNAME_PLZ_CHANGE, hostname) \ 99 | .replace(PORT_PLZ_CHANGE, port) \ 100 | .replace(HTTPS_PLZ_CHANGE, https) \ 101 | .replace(REDIS_HOST_PLZ_CHANGE, redis_host) \ 102 | .replace(REDIS_PORT_PLZ_CHANGE, redis_port) \ 103 | .replace(REDIS_PASSWORD_PLZ_CHANGE, redis_password) \ 104 | .replace(BASE64_MEDIA_PLZ_CHANGE, base64_media) \ 105 | .replace(TITLE_PLZ_CHANGE, title) \ 106 | .replace(THEME_PLZ_CHANGE, theme) \ 107 | .replace(INFINITE_SCROLL_PLZ_CHANGE, infinite_scroll) \ 108 | .replace(ENABLE_DEBUG_PLZ_CHANGE, enable_debug) \ 109 | .replace(RSS_MIUNTES_PLZ_CHANGE, rss_minutes) 110 | 111 | 112 | if __name__ == "__main__": 113 | if len(sys.argv) != 2: 114 | print("Usage: python3 gen_nitter_conf.py ") 115 | sys.exit(1) 116 | 117 | output_file = sys.argv[1] 118 | with open(output_file, "w") as f: 119 | f.write(main()) 120 | --------------------------------------------------------------------------------