├── 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 |
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 |
--------------------------------------------------------------------------------