├── tests
├── requirements.txt
├── test_search.py
├── test_thread.py
├── test_quote.py
├── test_timeline.py
├── test_profile.py
├── base.py
├── test_tweet_media.py
├── test_card.py
└── test_tweet.py
├── .github
├── FUNDING.yml
└── workflows
│ ├── run-tests.yml
│ └── build-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
├── apple-touch-icon.png
├── fonts
│ ├── fontello.eot
│ ├── fontello.ttf
│ ├── fontello.woff
│ ├── fontello.woff2
│ └── LICENSE.txt
├── 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
├── .dockerignore
├── src
├── experimental
│ ├── parser.nim
│ ├── types
│ │ ├── graphuser.nim
│ │ ├── common.nim
│ │ ├── timeline.nim
│ │ ├── graphlistmembers.nim
│ │ ├── user.nim
│ │ └── unifiedcard.nim
│ └── parser
│ │ ├── utils.nim
│ │ ├── timeline.nim
│ │ ├── graphql.nim
│ │ ├── slices.nim
│ │ ├── user.nim
│ │ └── unifiedcard.nim
├── routes
│ ├── debug.nim
│ ├── unsupported.nim
│ ├── resolver.nim
│ ├── embed.nim
│ ├── preferences.nim
│ ├── search.nim
│ ├── router_utils.nim
│ ├── list.nim
│ ├── status.nim
│ ├── media.nim
│ ├── rss.nim
│ └── timeline.nim
├── sass
│ ├── tweet
│ │ ├── embed.scss
│ │ ├── poll.scss
│ │ ├── video.scss
│ │ ├── quote.scss
│ │ ├── card.scss
│ │ ├── thread.scss
│ │ ├── media.scss
│ │ └── _base.scss
│ ├── general.scss
│ ├── include
│ │ ├── _variables.scss
│ │ └── _mixins.css
│ ├── profile
│ │ ├── _base.scss
│ │ ├── photo-rail.scss
│ │ └── card.scss
│ ├── navbar.scss
│ ├── search.scss
│ ├── timeline.scss
│ ├── index.scss
│ └── inputs.scss
├── prefs.nim
├── views
│ ├── feature.nim
│ ├── opensearch.nimf
│ ├── embed.nim
│ ├── about.nim
│ ├── list.nim
│ ├── preferences.nim
│ ├── status.nim
│ ├── renderutils.nim
│ ├── profile.nim
│ ├── timeline.nim
│ ├── search.nim
│ ├── rss.nimf
│ └── general.nim
├── http_pool.nim
├── utils.nim
├── config.nim
├── nitter.nim
├── query.nim
├── consts.nim
├── apiutils.nim
└── tokens.nim
├── tools
├── gencss.nim
└── rendermd.nim
├── .gitignore
├── config.nims
├── Dockerfile.arm64
├── Dockerfile
├── nitter.nimble
├── docker-compose.yml
├── .travis.yml
└── nitter.example.conf
/tests/requirements.txt:
--------------------------------------------------------------------------------
1 | seleniumbase
2 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psegovias/nitter/master/screenshot.png
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psegovias/nitter/master/public/logo.png
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | *.png
2 | *.md
3 | LICENSE
4 | docker-compose.yml
5 | Dockerfile
6 | tests/
7 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psegovias/nitter/master/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psegovias/nitter/master/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psegovias/nitter/master/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psegovias/nitter/master/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/fonts/fontello.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psegovias/nitter/master/public/fonts/fontello.eot
--------------------------------------------------------------------------------
/public/fonts/fontello.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psegovias/nitter/master/public/fonts/fontello.ttf
--------------------------------------------------------------------------------
/public/fonts/fontello.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psegovias/nitter/master/public/fonts/fontello.woff
--------------------------------------------------------------------------------
/public/fonts/fontello.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psegovias/nitter/master/public/fonts/fontello.woff2
--------------------------------------------------------------------------------
/src/experimental/parser.nim:
--------------------------------------------------------------------------------
1 | import parser/[user, graphql, timeline]
2 | export user, graphql, timeline
3 |
--------------------------------------------------------------------------------
/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/psegovias/nitter/master/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-384x384.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psegovias/nitter/master/public/android-chrome-384x384.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psegovias/nitter/master/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 |
--------------------------------------------------------------------------------
/public/css/themes/auto_(twitter).css:
--------------------------------------------------------------------------------
1 | @import "twitter_dark.css" (prefers-color-scheme: dark);
2 | @import "twitter.css" (prefers-color-scheme: light);
3 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 | dump.rdb
14 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/routes/debug.nim:
--------------------------------------------------------------------------------
1 | # SPDX-License-Identifier: AGPL-3.0-only
2 | import jester
3 | import router_utils
4 | import ".."/[tokens, types]
5 |
6 | proc createDebugRouter*(cfg: Config) =
7 | router debug:
8 | get "/.tokens":
9 | cond cfg.enableDebug
10 | respJson getPoolJson()
11 |
--------------------------------------------------------------------------------
/src/experimental/types/graphuser.nim:
--------------------------------------------------------------------------------
1 | import options
2 | import user
3 |
4 | type
5 | GraphUser* = object
6 | data*: tuple[user: UserData]
7 |
8 | UserData* = object
9 | result*: UserResult
10 |
11 | UserResult = object
12 | legacy*: RawUser
13 | restId*: string
14 | reason*: Option[string]
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | hint("XDeclaredButNotUsed", off)
11 | hint("XCannotRaiseY", off)
12 | hint("User", off)
13 |
14 | const
15 | nimVersion = (major: NimMajor, minor: NimMinor, patch: NimPatch)
16 |
17 | when nimVersion >= (1, 6, 0):
18 | warning("HoleEnumConv", off)
19 |
--------------------------------------------------------------------------------
/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 | import user
3 |
4 | type
5 | Search* = object
6 | globalObjects*: GlobalObjects
7 | timeline*: Timeline
8 |
9 | GlobalObjects = object
10 | users*: Table[string, RawUser]
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/Dockerfile.arm64:
--------------------------------------------------------------------------------
1 | FROM alpine:3.17 as nim
2 | LABEL maintainer="setenforce@protonmail.com"
3 |
4 | RUN apk --no-cache add gcc git libc-dev libsass-dev "nim=1.6.8-r0" nimble pcre
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.17
17 | WORKDIR /src/
18 | RUN apk --no-cache add ca-certificates pcre 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 | CMD ./nitter
24 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM nimlang/nim:1.6.10-alpine-regular as nim
2 | LABEL maintainer="setenforce@protonmail.com"
3 |
4 | RUN apk --no-cache add libsass-dev pcre
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:latest
17 | WORKDIR /src/
18 | RUN apk --no-cache add pcre ca-certificates
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 |
--------------------------------------------------------------------------------
/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)
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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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.4.8"
14 | requires "jester#baca3f"
15 | requires "karax#9ee695b"
16 | requires "sass#7dfdd03"
17 | requires "nimcrypto#4014ef9"
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#ea811be"
26 |
27 |
28 | # Tasks
29 |
30 | task scss, "Generate css":
31 | exec "nimble c --hint[Processing]:off -d:danger -r tools/gencss"
32 |
33 | task md, "Render md":
34 | exec "nimble c --hint[Processing]:off -d:danger -r tools/rendermd"
35 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | $icon_text: $fg_color;
32 |
33 | $tab: $fg_color;
34 | $tab_selected: $accent;
35 |
36 | $shadow: rgba(0,0,0,.6);
37 | $shadow_dark: rgba(0,0,0,.2);
38 |
39 | //fonts
40 | $font_0: Helvetica Neue;
41 | $font_1: Helvetica;
42 | $font_2: Arial;
43 | $font_3: sans-serif;
44 | $font_4: fontello;
45 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/experimental/types/user.nim:
--------------------------------------------------------------------------------
1 | import options
2 | import common
3 |
4 | type
5 | RawUser* = object
6 | idStr*: string
7 | name*: string
8 | screenName*: string
9 | location*: string
10 | description*: string
11 | entities*: Entities
12 | createdAt*: string
13 | followersCount*: int
14 | friendsCount*: int
15 | favouritesCount*: int
16 | statusesCount*: int
17 | mediaCount*: int
18 | verified*: bool
19 | protected*: bool
20 | profileLinkColor*: string
21 | profileBannerUrl*: string
22 | profileImageUrlHttps*: string
23 | profileImageExtensions*: Option[ImageExtensions]
24 | pinnedTweetIdsStr*: seq[string]
25 |
26 | Entities* = object
27 | url*: Urls
28 | description*: Urls
29 |
30 | Urls* = object
31 | urls*: seq[Url]
32 |
33 | ImageExtensions = object
34 | mediaColor*: tuple[r: Ok]
35 |
36 | Ok = object
37 | ok*: Palette
38 |
39 | Palette = object
40 | palette*: seq[tuple[rgb: Color]]
41 |
42 | Color* = object
43 | red*, green*, blue*: int
44 |
--------------------------------------------------------------------------------
/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 convo = await getTweet(@"id")
14 | if convo == nil or convo.tweet == nil or convo.tweet.video.isNone:
15 | resp Http404
16 |
17 | resp renderVideoEmbed(convo.tweet, cfg, request)
18 |
19 | get "/@user/status/@id/embed":
20 | let
21 | convo = await getTweet(@"id")
22 | prefs = cookiePrefs()
23 | path = getPath()
24 |
25 | if convo == nil or convo.tweet == nil:
26 | resp Http404
27 |
28 | resp renderTweetEmbed(convo.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/parser/timeline.nim:
--------------------------------------------------------------------------------
1 | import std/[strutils, tables]
2 | import jsony
3 | import user, ../types/timeline
4 | from ../../types import Result, User
5 |
6 | proc getId(id: string): string {.inline.} =
7 | let start = id.rfind("-")
8 | if start < 0: return id
9 | id[start + 1 ..< id.len]
10 |
11 | proc parseUsers*(json: string; after=""): Result[User] =
12 | result = Result[User](beginning: after.len == 0)
13 |
14 | let raw = json.fromJson(Search)
15 | if raw.timeline.instructions.len == 0:
16 | return
17 |
18 | for i in raw.timeline.instructions:
19 | if i.addEntries.entries.len > 0:
20 | for e in i.addEntries.entries:
21 | let id = e.entryId.getId
22 | if e.entryId.startsWith("user"):
23 | if id in raw.globalObjects.users:
24 | result.content.add toUser raw.globalObjects.users[id]
25 | elif e.entryId.startsWith("cursor"):
26 | let cursor = e.content.operation.cursor
27 | if cursor.cursorType == "Top":
28 | result.top = cursor.value
29 | elif cursor.cursorType == "Bottom":
30 | result.bottom = cursor.value
31 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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))
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 |
--------------------------------------------------------------------------------
/.github/workflows/run-tests.yml:
--------------------------------------------------------------------------------
1 | name: Run tests
2 |
3 | on:
4 | push:
5 | paths-ignore:
6 | - "*.md"
7 |
8 | jobs:
9 | test:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v3
13 | with:
14 | fetch-depth: 0
15 | - name: Cache nimble
16 | id: cache-nimble
17 | uses: actions/cache@v3
18 | with:
19 | path: ~/.nimble
20 | key: nimble-${{ hashFiles('*.nimble') }}
21 | restore-keys: "nimble-"
22 | - uses: actions/setup-python@v4
23 | with:
24 | python-version: "3.10"
25 | cache: "pip"
26 | - uses: jiro4989/setup-nim-action@v1
27 | with:
28 | nim-version: "1.x"
29 | - run: nimble build -d:release -Y
30 | - run: pip install seleniumbase
31 | - run: seleniumbase install chromedriver
32 | - uses: supercharge/redis-github-action@1.5.0
33 | - name: Prepare Nitter
34 | run: |
35 | sudo apt install libsass-dev -y
36 | cp nitter.example.conf nitter.conf
37 | nimble md
38 | nimble scss
39 | - name: Run tests
40 | run: |
41 | ./nitter &
42 | pytest -n4 tests
43 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3"
2 |
3 | services:
4 |
5 | nitter:
6 | image: zedeus/nitter:latest
7 | container_name: nitter
8 | ports:
9 | - "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy
10 | volumes:
11 | - ./nitter.conf:/src/nitter.conf:Z,ro
12 | depends_on:
13 | - nitter-redis
14 | restart: unless-stopped
15 | healthcheck:
16 | test: wget -nv --tries=1 --spider http://127.0.0.1:8080/Jack/status/20 || exit 1
17 | interval: 30s
18 | timeout: 5s
19 | retries: 2
20 | user: "998:998"
21 | read_only: true
22 | security_opt:
23 | - no-new-privileges:true
24 | cap_drop:
25 | - ALL
26 |
27 | nitter-redis:
28 | image: redis:6-alpine
29 | container_name: nitter-redis
30 | command: redis-server --save 60 1 --loglevel warning
31 | volumes:
32 | - nitter-redis:/data
33 | restart: unless-stopped
34 | healthcheck:
35 | test: redis-cli ping
36 | interval: 30s
37 | timeout: 5s
38 | retries: 2
39 | user: "999:1000"
40 | read_only: true
41 | security_opt:
42 | - no-new-privileges:true
43 | cap_drop:
44 | - ALL
45 |
46 | volumes:
47 | nitter-redis:
48 |
--------------------------------------------------------------------------------
/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 ProtocolError:
43 | # Twitter closed the connection, retry
44 | body
45 | finally:
46 | pool.release(c, badClient)
47 |
--------------------------------------------------------------------------------
/src/experimental/parser/graphql.nim:
--------------------------------------------------------------------------------
1 | import options
2 | import jsony
3 | import user, ../types/[graphuser, graphlistmembers]
4 | from ../../types import User, Result, Query, QueryKind
5 |
6 | proc parseGraphUser*(json: string): User =
7 | let raw = json.fromJson(GraphUser)
8 |
9 | if raw.data.user.result.reason.get("") == "Suspended":
10 | return User(suspended: true)
11 |
12 | result = toUser raw.data.user.result.legacy
13 | result.id = raw.data.user.result.restId
14 |
15 | proc parseGraphListMembers*(json, cursor: string): Result[User] =
16 | result = Result[User](
17 | beginning: cursor.len == 0,
18 | query: Query(kind: userList)
19 | )
20 |
21 | let raw = json.fromJson(GraphListMembers)
22 | for instruction in raw.data.list.membersTimeline.timeline.instructions:
23 | if instruction.kind == "TimelineAddEntries":
24 | for entry in instruction.entries:
25 | case entry.content.entryType
26 | of TimelineTimelineItem:
27 | let userResult = entry.content.itemContent.userResults.result
28 | if userResult.restId.len > 0:
29 | result.content.add toUser userResult.legacy
30 | of TimelineTimelineCursor:
31 | if entry.content.cursorType == "Bottom":
32 | result.bottom = entry.content.value
33 |
--------------------------------------------------------------------------------
/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/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 | let users = await getSearch[User](query, getCursor())
31 | resp renderMain(renderUserSearch(users, prefs), request, cfg, prefs, title)
32 | of tweets:
33 | let
34 | tweets = await getSearch[Tweet](query, getCursor())
35 | rss = "/search/rss?" & genQueryUrl(query)
36 | resp renderMain(renderTweetSearch(tweets, cfg, prefs, getPath()),
37 | request, cfg, prefs, title, rss=rss)
38 | else:
39 | resp Http404, showError("Invalid search", cfg)
40 |
41 | get "/hashtag/@hash":
42 | redirect("/search?q=" & encodeUrl("#" & @"hash"))
43 |
44 | get "/opensearch":
45 | let url = getUrlPrefix(cfg) & "/search?q="
46 | resp Http200, {"Content-Type": "application/opensearchdescription+xml"},
47 | generateOpenSearchXML(cfg.title, cfg.hostname, url)
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 | ]
21 |
22 | proc setHmacKey*(key: string) =
23 | hmacKey = key
24 |
25 | proc setProxyEncoding*(state: bool) =
26 | base64Media = state
27 |
28 | proc getHmac*(data: string): string =
29 | ($hmac(sha256, hmacKey, data))[0 .. 12]
30 |
31 | proc getVidUrl*(link: string): string =
32 | if link.len == 0: return
33 | let sig = getHmac(link)
34 | if base64Media:
35 | &"/video/enc/{sig}/{encode(link, safe=true)}"
36 | else:
37 | &"/video/{sig}/{encodeUrl(link)}"
38 |
39 | proc getPicUrl*(link: string): string =
40 | if base64Media:
41 | &"/pic/enc/{encode(link, safe=true)}"
42 | else:
43 | &"/pic/{encodeUrl(link)}"
44 |
45 | proc getOrigPicUrl*(link: string): string =
46 | if base64Media:
47 | &"/pic/orig/enc/{encode(link, safe=true)}"
48 | else:
49 | &"/pic/orig/{encodeUrl(link)}"
50 |
51 | proc filterParams*(params: Table): seq[(string, string)] =
52 | for p in params.pairs():
53 | if p[1].len > 0 and p[0] notin nitterParams:
54 | result.add p
55 |
56 | proc isTwitterUrl*(uri: Uri): bool =
57 | uri.hostname in twitterDomains
58 |
59 | proc isTwitterUrl*(url: string): bool =
60 | parseUri(url).hostname in twitterDomains
61 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 | margin-top: 2px;
74 | display: block;
75 | fill: var(--fg_nav);
76 |
77 | &:hover {
78 | fill: var(--accent_light);
79 | }
80 | }
81 |
82 | .icon-info:before {
83 | margin: 0 -3px;
84 | }
85 |
86 | .icon-cog {
87 | font-size: 15px;
88 | }
89 |
--------------------------------------------------------------------------------
/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 | macro renderPrefs*(): untyped =
9 | result = nnkCall.newTree(
10 | ident("buildHtml"), ident("tdiv"), nnkStmtList.newTree())
11 |
12 | for header, options in prefList:
13 | result[2].add nnkCall.newTree(
14 | ident("legend"),
15 | nnkStmtList.newTree(
16 | nnkCommand.newTree(ident("text"), newLit(header))))
17 |
18 | for pref in options:
19 | let procName = ident("gen" & capitalizeAscii($pref.kind))
20 | let state = nnkDotExpr.newTree(ident("prefs"), ident(pref.name))
21 | var stmt = nnkStmtList.newTree(
22 | nnkCall.newTree(procName, newLit(pref.name), newLit(pref.label), state))
23 |
24 | case pref.kind
25 | of checkbox: discard
26 | of input: stmt[0].add newLit(pref.placeholder)
27 | of select:
28 | if pref.name == "theme":
29 | stmt[0].add ident("themes")
30 | else:
31 | stmt[0].add newLit(pref.options)
32 |
33 | result[2].add stmt
34 |
35 | proc renderPreferences*(prefs: Prefs; path: string; themes: seq[string]): VNode =
36 | buildHtml(tdiv(class="overlay-panel")):
37 | fieldset(class="preferences"):
38 | form(`method`="post", action="/saveprefs", autocomplete="off"):
39 | refererField path
40 |
41 | renderPrefs()
42 |
43 | h4(class="note"):
44 | text "Preferences are stored client-side using cookies without any personal information."
45 |
46 | button(`type`="submit", class="pref-submit"):
47 | text "Save preferences"
48 |
49 | buttonReferer "/resetprefs", "Reset preferences", path, class="pref-reset"
50 |
--------------------------------------------------------------------------------
/.github/workflows/build-docker.yml:
--------------------------------------------------------------------------------
1 | name: Docker
2 |
3 | on:
4 | push:
5 | paths-ignore:
6 | - "README.md"
7 | branches:
8 | - master
9 |
10 | jobs:
11 | build-docker-amd64:
12 | runs-on: buildjet-2vcpu-ubuntu-2204
13 | steps:
14 | - uses: actions/checkout@v3
15 | with:
16 | fetch-depth: 0
17 | - name: Set up Docker Buildx
18 | id: buildx
19 | uses: docker/setup-buildx-action@v2
20 | with:
21 | version: latest
22 | - name: Login to DockerHub
23 | uses: docker/login-action@v2
24 | with:
25 | username: ${{ secrets.DOCKER_USERNAME }}
26 | password: ${{ secrets.DOCKER_PASSWORD }}
27 | - name: Build and push AMD64 Docker image
28 | uses: docker/build-push-action@v3
29 | with:
30 | context: .
31 | file: ./Dockerfile
32 | platforms: linux/amd64
33 | push: true
34 | tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
35 | build-docker-arm64:
36 | runs-on: buildjet-2vcpu-ubuntu-2204-arm
37 | steps:
38 | - uses: actions/checkout@v3
39 | with:
40 | fetch-depth: 0
41 | - name: Set up Docker Buildx
42 | id: buildx
43 | uses: docker/setup-buildx-action@v2
44 | with:
45 | version: latest
46 | - name: Login to DockerHub
47 | uses: docker/login-action@v2
48 | with:
49 | username: ${{ secrets.DOCKER_USERNAME }}
50 | password: ${{ secrets.DOCKER_PASSWORD }}
51 | - name: Build and push ARM64 Docker image
52 | uses: docker/build-push-action@v3
53 | with:
54 | context: .
55 | file: ./Dockerfile.arm64
56 | platforms: linux/arm64
57 | push: true
58 | tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64
59 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | jobs:
2 | include:
3 | - stage: test
4 | if: (NOT type IN (pull_request)) AND (branch = master)
5 | dist: bionic
6 | language: python
7 | python:
8 | - 3.6
9 | services:
10 | - docker
11 | - xvfb
12 | script:
13 | - sudo apt update
14 | - sudo apt install --force-yes chromium-chromedriver
15 | - wget https://www.dropbox.com/s/ckuoaubd1crrj2k/Linux_x64_737173_chrome-linux.zip -O chrome.zip
16 | - unzip chrome.zip
17 | - cd chrome-linux
18 | - sudo rm /usr/bin/google-chrome
19 | - sudo ln -s chrome /usr/bin/google-chrome
20 | - cd ..
21 | - pip3 install --upgrade pip
22 | - pip3 install -U seleniumbase pytest
23 | - docker run -d -p 127.0.0.1:8080:8080/tcp $IMAGE_NAME:$TRAVIS_COMMIT
24 | - sleep 10
25 | - cd tests
26 | - pytest --headless -n 8 --reruns 10 --reruns-delay 2
27 | - stage: pr
28 | if: type IN (pull_request)
29 | dist: bionic
30 | language: python
31 | python:
32 | - 3.6
33 | services:
34 | - docker
35 | - xvfb
36 | script:
37 | - sudo apt update
38 | - sudo apt install --force-yes chromium-chromedriver
39 | - wget https://www.dropbox.com/s/ckuoaubd1crrj2k/Linux_x64_737173_chrome-linux.zip -O chrome.zip
40 | - unzip chrome.zip
41 | - cd chrome-linux
42 | - sudo rm /usr/bin/google-chrome
43 | - sudo ln -s chrome /usr/bin/google-chrome
44 | - cd ..
45 | - pip3 install --upgrade pip
46 | - pip3 install -U seleniumbase pytest
47 | - docker build -t $IMAGE_NAME:$TRAVIS_COMMIT .
48 | - docker run -d -p 127.0.0.1:8080:8080/tcp $IMAGE_NAME:$TRAVIS_COMMIT
49 | - sleep 10
50 | - cd tests
51 | - pytest --headless -n 8 --reruns 3 --reruns-delay 2
52 |
--------------------------------------------------------------------------------
/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 | export getListTimeline, getGraphList
10 |
11 | template respList*(list, timeline, title, vnode: typed) =
12 | if list.id.len == 0 or list.name.len == 0:
13 | resp Http404, showError(&"""List "{@"id"}" not found""", cfg)
14 |
15 | let
16 | html = renderList(vnode, timeline.query, list)
17 | rss = &"""/i/lists/{@"id"}/rss"""
18 |
19 | resp renderMain(html, request, cfg, prefs, titleText=title, rss=rss, banner=list.banner)
20 |
21 | proc title*(list: List): string =
22 | &"@{list.username}/{list.name}"
23 |
24 | proc createListRouter*(cfg: Config) =
25 | router list:
26 | get "/@name/lists/@slug/?":
27 | cond '.' notin @"name"
28 | cond @"name" != "i"
29 | cond @"slug" != "memberships"
30 | let
31 | slug = decodeUrl(@"slug")
32 | list = await getCachedList(@"name", slug)
33 | if list.id.len == 0:
34 | resp Http404, showError(&"""List "{@"slug"}" not found""", cfg)
35 | redirect(&"/i/lists/{list.id}")
36 |
37 | get "/i/lists/@id/?":
38 | cond '.' notin @"id"
39 | let
40 | prefs = cookiePrefs()
41 | list = await getCachedList(id=(@"id"))
42 | timeline = await getListTimeline(list.id, getCursor())
43 | vnode = renderTimelineTweets(timeline, prefs, request.path)
44 | respList(list, timeline, list.title, vnode)
45 |
46 | get "/i/lists/@id/members":
47 | cond '.' notin @"id"
48 | let
49 | prefs = cookiePrefs()
50 | list = await getCachedList(id=(@"id"))
51 | members = await getGraphListMembers(list, getCursor())
52 | respList(list, members, list.title, renderTimelineUsers(members, prefs, request.path))
53 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/config.nim:
--------------------------------------------------------------------------------
1 | # SPDX-License-Identifier: AGPL-3.0-only
2 | import parsecfg except Config
3 | import types, strutils
4 | from os import getEnv
5 |
6 | proc get*[T](config: parseCfg.Config; section, key: string; default: T): T =
7 | let val = config.getSectionValue(section, key)
8 | if val.len == 0: return default
9 |
10 | when T is int: parseInt(val)
11 | elif T is bool: parseBool(val)
12 | elif T is string: val
13 |
14 | proc getConfig*(path: string): (Config, parseCfg.Config) =
15 | var cfg = loadConfig(path)
16 |
17 | let conf = Config(
18 | # Server
19 | address: cfg.get("Server", "address", "0.0.0.0"),
20 | port: cfg.get("Server", "port", 8080),
21 | useHttps: cfg.get("Server", "https", true),
22 | httpMaxConns: cfg.get("Server", "httpMaxConnections", 100),
23 | staticDir: cfg.get("Server", "staticDir", "./public"),
24 | title: cfg.get("Server", "title", "Nitter"),
25 | hostname: cfg.get("Server", "hostname", "nitter.net"),
26 |
27 | # Cache
28 | listCacheTime: cfg.get("Cache", "listMinutes", 120),
29 | rssCacheTime: cfg.get("Cache", "rssMinutes", 10),
30 |
31 | redisHost: cfg.get("Cache", "redisHost", "localhost"),
32 | redisPort: cfg.get("Cache", "redisPort", 6379),
33 | redisConns: cfg.get("Cache", "redisConnections", 20),
34 | redisMaxConns: cfg.get("Cache", "redisMaxConnections", 30),
35 | redisPassword: cfg.get("Cache", "redisPassword", ""),
36 |
37 | # Config
38 | hmacKey: cfg.get("Config", "hmacKey", "secretkey"),
39 | base64Media: cfg.get("Config", "base64Media", false),
40 | minTokens: cfg.get("Config", "tokenCount", 10),
41 | enableRss: cfg.get("Config", "enableRSS", true),
42 | enableDebug: cfg.get("Config", "enableDebug", false),
43 | proxy: cfg.get("Config", "proxy", ""),
44 | proxyAuth: cfg.get("Config", "proxyAuth", ""),
45 | cookieHeader: cfg.get("Config", "cookieHeader", ""),
46 | xCsrfToken: cfg.get("Config", "xCsrfToken", "")
47 | )
48 |
49 | return (conf, cfg)
50 |
51 |
52 | let configPath = getEnv("NITTER_CONF_FILE", "./nitter.conf")
53 | let (cfg*, fullCfg*) = getConfig(configPath)
54 |
--------------------------------------------------------------------------------
/nitter.example.conf:
--------------------------------------------------------------------------------
1 | [Server]
2 | address = "0.0.0.0"
3 | port = 8080
4 | https = false # disable to enable cookies when not using https
5 | httpMaxConnections = 100
6 | staticDir = "./public"
7 | title = "nitter"
8 | hostname = "nitter.net"
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 # connection pool size
17 | redisMaxConnections = 30
18 | # max, 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
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 187 requests.
32 | # the limit gets reset every 15 minutes, and the pool is filled up so there's
33 | # always at least $tokenCount usable tokens. again, only increase this if
34 | # you receive major bursts all the time
35 |
36 | #cookieHeader = "ct0=XXXXXXXXXXXXXXXXX; auth_token=XXXXXXXXXXXXXX" # authentication cookie of a logged in account, required for the likes tab and NSFW content
37 | #xCsrfToken = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX" # required for the likes tab and NSFW content
38 |
39 | # Change default preferences here, see src/prefs_impl.nim for a complete list
40 | [Preferences]
41 | theme = "Nitter"
42 | replaceTwitter = "nitter.net"
43 | replaceYouTube = "piped.video"
44 | replaceReddit = "teddit.net"
45 | proxyVideos = true
46 | hlsPlayback = false
47 | infiniteScroll = false
48 |
--------------------------------------------------------------------------------
/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 language', '@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 |
--------------------------------------------------------------------------------
/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 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 | verified: raw.verified,
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 parseUser*(json: string; username=""): User =
72 | handleErrors:
73 | case error.code
74 | of suspended: return User(username: username, suspended: true)
75 | of userNotFound: return
76 | else: echo "[error - parseUser]: ", error
77 |
78 | result = toUser json.fromJson(RawUser)
79 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | cond not @"id".any(c => not c.isDigit)
20 | let prefs = cookiePrefs()
21 |
22 | # used for the infinite scroll feature
23 | if @"scroll".len > 0:
24 | let replies = await getReplies(@"id", getCursor())
25 | if replies.content.len == 0:
26 | resp Http404, ""
27 | resp $renderReplies(replies, prefs, getPath())
28 |
29 | let conv = await getTweet(@"id", getCursor())
30 | if conv == nil:
31 | echo "nil conv"
32 |
33 | if conv == nil or conv.tweet == nil or conv.tweet.id == 0:
34 | var error = "Tweet not found"
35 | if conv != nil and conv.tweet != nil and conv.tweet.tombstone.len > 0:
36 | error = conv.tweet.tombstone
37 | resp Http404, showError(error, cfg)
38 |
39 | let
40 | title = pageTitle(conv.tweet)
41 | ogTitle = pageTitle(conv.tweet.user)
42 | desc = conv.tweet.text
43 |
44 | var
45 | images = conv.tweet.photos
46 | video = ""
47 |
48 | if conv.tweet.video.isSome():
49 | images = @[get(conv.tweet.video).thumb]
50 | video = getVideoEmbed(cfg, conv.tweet.id)
51 | elif conv.tweet.gif.isSome():
52 | images = @[get(conv.tweet.gif).thumb]
53 | video = getPicUrl(get(conv.tweet.gif).url)
54 | elif conv.tweet.card.isSome():
55 | let card = conv.tweet.card.get()
56 | if card.image.len > 0:
57 | images = @[card.image]
58 | elif card.video.isSome():
59 | images = @[card.video.get().thumb]
60 |
61 | let html = renderConversation(conv, prefs, getPath() & "#m")
62 | resp renderMain(html, request, cfg, prefs, title, desc, ogTitle,
63 | images=images, video=video)
64 |
65 | get "/@name/@s/@id/@m/?@i?":
66 | cond @"s" in ["status", "statuses"]
67 | cond @"m" in ["video", "photo"]
68 | redirect("/$1/status/$2" % [@"name", @"id"])
69 |
70 | get "/@name/statuses/@id/?":
71 | redirect("/$1/status/$2" % [@"name", @"id"])
72 |
73 | get "/i/web/status/@id":
74 | redirect("/i/status/" & @"id")
75 |
76 | get "/@name/thread/@id/?":
77 | redirect("/$1/status/$2" % [@"name", @"id"])
78 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | window.addEventListener('scroll', function() {
29 | if (loading) return;
30 | if (html.scrollTop + html.clientHeight >= html.scrollHeight - 3000) {
31 | loading = true;
32 | var loadMore = getLoadMore(document);
33 | if (loadMore == null) return;
34 |
35 | loadMore.children[0].text = "Loading...";
36 |
37 | var url = new URL(loadMore.children[0].href);
38 | url.searchParams.append('scroll', 'true');
39 |
40 | fetch(url.toString()).then(function (response) {
41 | return response.text();
42 | }).then(function (html) {
43 | var parser = new DOMParser();
44 | var doc = parser.parseFromString(html, 'text/html');
45 | loadMore.remove();
46 |
47 | for (var item of doc.querySelectorAll(itemClass)) {
48 | if (item.className == "timeline-item show-more") continue;
49 | if (isDuplicate(item, itemClass)) continue;
50 | if (isTweet) container.appendChild(item);
51 | else insertBeforeLast(container, item);
52 | }
53 |
54 | loading = false;
55 | const newLoadMore = getLoadMore(doc);
56 | if (newLoadMore == null) return;
57 | if (isTweet) container.appendChild(newLoadMore);
58 | else insertBeforeLast(container, newLoadMore);
59 | }).catch(function (err) {
60 | console.warn('Something went wrong.', err);
61 | loading = true;
62 | });
63 | }
64 | });
65 | };
66 | // @license-end
67 |
--------------------------------------------------------------------------------
/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 | }
18 |
19 | .pref-input {
20 | margin: 0 4px 0 0;
21 | flex-grow: 1;
22 | height: 23px;
23 | }
24 |
25 | input[type="text"] {
26 | height: calc(100% - 4px);
27 | width: calc(100% - 8px);
28 | }
29 |
30 | > label {
31 | display: inline;
32 | background-color: var(--bg_elements);
33 | color: var(--fg_color);
34 | border: 1px solid var(--accent_border);
35 | padding: 1px 6px 2px 6px;
36 | font-size: 14px;
37 | cursor: pointer;
38 | margin-bottom: 2px;
39 |
40 | @include input-colors;
41 | }
42 |
43 | @include create-toggle(search-panel, 200px);
44 | }
45 |
46 | .search-panel {
47 | width: 100%;
48 | max-height: 0;
49 | overflow: hidden;
50 | transition: max-height 0.4s;
51 |
52 | flex-grow: 1;
53 | font-weight: initial;
54 | text-align: left;
55 |
56 | > div {
57 | line-height: 1.7em;
58 | }
59 |
60 | .checkbox-container {
61 | display: inline;
62 | padding-right: unset;
63 | margin-bottom: unset;
64 | margin-left: 23px;
65 | }
66 |
67 | .checkbox {
68 | right: unset;
69 | left: -22px;
70 | }
71 |
72 | .checkbox-container .checkbox:after {
73 | top: -4px;
74 | }
75 | }
76 |
77 | .search-row {
78 | display: flex;
79 | flex-wrap: wrap;
80 | line-height: unset;
81 |
82 | > div {
83 | flex-grow: 1;
84 | flex-shrink: 1;
85 | }
86 |
87 | input {
88 | height: 21px;
89 | }
90 |
91 | .pref-input {
92 | display: block;
93 | padding-bottom: 5px;
94 |
95 | input {
96 | height: 21px;
97 | margin-top: 1px;
98 | }
99 | }
100 | }
101 |
102 | .search-toggles {
103 | flex-grow: 1;
104 | display: grid;
105 | grid-template-columns: repeat(6, auto);
106 | grid-column-gap: 10px;
107 | }
108 |
109 | .profile-tabs {
110 | @include search-resize(820px, 5);
111 | @include search-resize(725px, 4);
112 | @include search-resize(600px, 6);
113 | @include search-resize(560px, 5);
114 | @include search-resize(480px, 4);
115 | @include search-resize(410px, 3);
116 | }
117 |
118 | @include search-resize(560px, 5);
119 | @include search-resize(480px, 4);
120 | @include search-resize(410px, 3);
121 |
--------------------------------------------------------------------------------
/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 | @include breakable;
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_timeline.py:
--------------------------------------------------------------------------------
1 | from base import BaseTestCase, Timeline
2 | from parameterized import parameterized
3 |
4 | normal = [['mobile_test'], ['mobile_test_2']]
5 |
6 | after = [['mobile_test', 'HBaAgJPsqtGNhA0AAA%3D%3D'],
7 | ['mobile_test_2', 'HBaAgJPsqtGNhA0AAA%3D%3D']]
8 |
9 | no_more = [['mobile_test_8?cursor=HBaAwJCsk%2F6%2FtgQAAA%3D%3D']]
10 |
11 | empty = [['emptyuser'], ['mobile_test_10']]
12 |
13 | protected = [['mobile_test_7'], ['Empty_user']]
14 |
15 | photo_rail = [['mobile_test', [
16 | 'BzUnaDFCUAAmrjs', 'Bo0nDsYIYAIjqVn', 'Bos--KNIQAAA7Li', 'Boq1sDJIYAAxaoi',
17 | 'BonISmPIEAAhP3G', 'BoQbwJAIUAA0QCY', 'BoQbRQxIIAA3FWD', 'Bn8Qh8iIIAABXrG',
18 | 'Bn8QIG3IYAA0IGT', 'Bn8O3QeIUAAONai', 'Bn8NGViIAAATNG4', 'BkKovdrCUAAEz79',
19 | 'BkKoe_oCIAASAqr', 'BkKoRLNCAAAYfDf', 'BkKndxoCQAE1vFt', 'BPEmIbYCMAE44dl'
20 | ]]]
21 |
22 |
23 | class TweetTest(BaseTestCase):
24 | @parameterized.expand(normal)
25 | def test_timeline(self, username):
26 | self.open_nitter(username)
27 | self.assert_element_present(Timeline.older)
28 | self.assert_element_absent(Timeline.newest)
29 | self.assert_element_absent(Timeline.end)
30 | self.assert_element_absent(Timeline.none)
31 |
32 | @parameterized.expand(after)
33 | def test_after(self, username, cursor):
34 | self.open_nitter(f'{username}?cursor={cursor}')
35 | self.assert_element_present(Timeline.newest)
36 | self.assert_element_present(Timeline.older)
37 | self.assert_element_absent(Timeline.end)
38 | self.assert_element_absent(Timeline.none)
39 |
40 | @parameterized.expand(no_more)
41 | def test_no_more(self, username):
42 | self.open_nitter(username)
43 | self.assert_text('No more items', Timeline.end)
44 | self.assert_element_present(Timeline.newest)
45 | self.assert_element_absent(Timeline.older)
46 |
47 | @parameterized.expand(empty)
48 | def test_empty(self, username):
49 | self.open_nitter(username)
50 | self.assert_text('No items found', Timeline.none)
51 | self.assert_element_absent(Timeline.newest)
52 | self.assert_element_absent(Timeline.older)
53 | self.assert_element_absent(Timeline.end)
54 |
55 | @parameterized.expand(protected)
56 | def test_protected(self, username):
57 | self.open_nitter(username)
58 | self.assert_text('This account\'s tweets are protected.', Timeline.protected)
59 | self.assert_element_absent(Timeline.newest)
60 | self.assert_element_absent(Timeline.older)
61 | self.assert_element_absent(Timeline.end)
62 |
63 | @parameterized.expand(photo_rail)
64 | def test_photo_rail(self, username, images):
65 | self.open_nitter(username)
66 | self.assert_element_visible(Timeline.photo_rail)
67 | for i, url in enumerate(images):
68 | img = self.get_attribute(Timeline.photo_rail + f' a:nth-child({i + 1}) img', 'src')
69 | self.assertIn(url, img)
70 |
--------------------------------------------------------------------------------
/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 |
6 | import jester
7 |
8 | import types, config, prefs, formatters, redis_cache, http_pool, tokens
9 | import views/[general, about]
10 | import routes/[
11 | preferences, timeline, status, media, search, rss, list, debug,
12 | unsupported, embed, resolver, router_utils]
13 |
14 | const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
15 | const issuesUrl = "https://github.com/zedeus/nitter/issues"
16 |
17 | if not cfg.enableDebug:
18 | # Silence Jester's query warning
19 | addHandler(newConsoleLogger())
20 | setLogFilter(lvlError)
21 |
22 | stdout.write &"Starting Nitter at {getUrlPrefix(cfg)}\n"
23 | stdout.flushFile
24 |
25 | updateDefaultPrefs(fullCfg)
26 | setCacheTimes(cfg)
27 | setHmacKey(cfg.hmacKey)
28 | setProxyEncoding(cfg.base64Media)
29 | setMaxHttpConns(cfg.httpMaxConns)
30 | setHttpProxy(cfg.proxy, cfg.proxyAuth)
31 | initAboutPage(cfg.staticDir)
32 |
33 | waitFor initRedisPool(cfg)
34 | stdout.write &"Connected to Redis at {cfg.redisHost}:{cfg.redisPort}\n"
35 | stdout.flushFile
36 |
37 | asyncCheck initTokenPool(cfg)
38 |
39 | createUnsupportedRouter(cfg)
40 | createResolverRouter(cfg)
41 | createPrefRouter(cfg)
42 | createTimelineRouter(cfg)
43 | createListRouter(cfg)
44 | createStatusRouter(cfg)
45 | createSearchRouter(cfg)
46 | createMediaRouter(cfg)
47 | createEmbedRouter(cfg)
48 | createRssRouter(cfg)
49 | createDebugRouter(cfg)
50 |
51 | settings:
52 | port = Port(cfg.port)
53 | staticDir = cfg.staticDir
54 | bindAddr = cfg.address
55 | reusePort = true
56 |
57 | routes:
58 | get "/":
59 | resp renderMain(renderSearch(), request, cfg, themePrefs())
60 |
61 | get "/about":
62 | resp renderMain(renderAbout(), request, cfg, themePrefs())
63 |
64 | get "/explore":
65 | redirect("/about")
66 |
67 | get "/help":
68 | redirect("/about")
69 |
70 | get "/i/redirect":
71 | let url = decodeUrl(@"url")
72 | if url.len == 0: resp Http404
73 | redirect(replaceUrls(url, cookiePrefs()))
74 |
75 | error Http404:
76 | resp Http404, showError("Page not found", cfg)
77 |
78 | error InternalError:
79 | echo error.exc.name, ": ", error.exc.msg
80 | const link = a("open a GitHub issue", href = issuesUrl)
81 | resp Http500, showError(
82 | &"An error occurred, please {link} with the URL you tried to visit.", cfg)
83 |
84 | error RateLimitError:
85 | const link = a("another instance", href = instancesUrl)
86 | resp Http429, showError(
87 | &"Instance has been rate limited.
Use {link} or try again later.", cfg)
88 |
89 | extend unsupported, ""
90 | extend preferences, ""
91 | extend resolver, ""
92 | extend rss, ""
93 | extend search, ""
94 | extend timeline, ""
95 | extend list, ""
96 | extend status, ""
97 | extend media, ""
98 | extend embed, ""
99 | extend debug, ""
100 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/src/experimental/types/unifiedcard.nim:
--------------------------------------------------------------------------------
1 | import options, tables
2 | from ../../types import VideoType, VideoVariant
3 |
4 | type
5 | UnifiedCard* = object
6 | componentObjects*: Table[string, Component]
7 | destinationObjects*: Table[string, Destination]
8 | mediaEntities*: Table[string, MediaEntity]
9 | appStoreData*: Table[string, seq[AppStoreData]]
10 |
11 | ComponentType* = enum
12 | details
13 | media
14 | swipeableMedia
15 | buttonGroup
16 | appStoreDetails
17 | twitterListDetails
18 | communityDetails
19 | mediaWithDetailsHorizontal
20 | unknown
21 |
22 | Component* = object
23 | kind*: ComponentType
24 | data*: ComponentData
25 |
26 | ComponentData* = object
27 | id*: string
28 | appId*: string
29 | mediaId*: string
30 | destination*: string
31 | title*: Text
32 | subtitle*: Text
33 | name*: Text
34 | memberCount*: int
35 | mediaList*: seq[MediaItem]
36 | topicDetail*: tuple[title: Text]
37 |
38 | MediaItem* = object
39 | id*: string
40 | destination*: string
41 |
42 | Destination* = object
43 | kind*: string
44 | data*: tuple[urlData: UrlData]
45 |
46 | UrlData* = object
47 | url*: string
48 | vanity*: string
49 |
50 | MediaType* = enum
51 | photo, video, model3d
52 |
53 | MediaEntity* = object
54 | kind*: MediaType
55 | mediaUrlHttps*: string
56 | videoInfo*: Option[VideoInfo]
57 |
58 | VideoInfo* = object
59 | durationMillis*: int
60 | variants*: seq[VideoVariant]
61 |
62 | AppType* = enum
63 | androidApp, iPhoneApp, iPadApp
64 |
65 | AppStoreData* = object
66 | kind*: AppType
67 | id*: string
68 | title*: Text
69 | category*: Text
70 |
71 | Text = object
72 | content: string
73 |
74 | HasTypeField = Component | Destination | MediaEntity | AppStoreData
75 |
76 | converter fromText*(text: Text): string = text.content
77 |
78 | proc renameHook*(v: var HasTypeField; fieldName: var string) =
79 | if fieldName == "type":
80 | fieldName = "kind"
81 |
82 | proc enumHook*(s: string; v: var ComponentType) =
83 | v = case s
84 | of "details": details
85 | of "media": media
86 | of "swipeable_media": swipeableMedia
87 | of "button_group": buttonGroup
88 | of "app_store_details": appStoreDetails
89 | of "twitter_list_details": twitterListDetails
90 | of "community_details": communityDetails
91 | of "media_with_details_horizontal": mediaWithDetailsHorizontal
92 | else: echo "ERROR: Unknown enum value (ComponentType): ", s; unknown
93 |
94 | proc enumHook*(s: string; v: var AppType) =
95 | v = case s
96 | of "android_app": androidApp
97 | of "iphone_app": iPhoneApp
98 | of "ipad_app": iPadApp
99 | else: echo "ERROR: Unknown enum value (AppType): ", s; androidApp
100 |
101 | proc enumHook*(s: string; v: var MediaType) =
102 | v = case s
103 | of "video": video
104 | of "photo": photo
105 | of "model3d": model3d
106 | else: echo "ERROR: Unknown enum value (MediaType): ", s; photo
107 |
--------------------------------------------------------------------------------
/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', '100'],
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_color = [
21 | ['nim_lang', '22, 25, 32'],
22 | ['rustlang', '35, 31, 32']
23 | ]
24 |
25 | banner_image = [
26 | ['mobile_test', 'profile_banners%2F82135242%2F1384108037%2F1500x500']
27 | ]
28 |
29 |
30 | class ProfileTest(BaseTestCase):
31 | @parameterized.expand(profiles)
32 | def test_data(self, username, fullname, bio, location, website, joinDate, mediaCount):
33 | self.open_nitter(username)
34 | self.assert_exact_text(fullname, Profile.fullname)
35 | self.assert_exact_text(f'@{username}', Profile.username)
36 |
37 | tests = [
38 | (bio, Profile.bio),
39 | (location, Profile.location),
40 | (website, Profile.website),
41 | (joinDate, Profile.joinDate),
42 | (mediaCount + " Photos and videos", Profile.mediaCount)
43 | ]
44 |
45 | for text, selector in tests:
46 | if len(text) > 0:
47 | self.assert_exact_text(text, selector)
48 | else:
49 | self.assert_element_absent(selector)
50 |
51 | @parameterized.expand(verified)
52 | def test_verified(self, username):
53 | self.open_nitter(username)
54 | self.assert_element_visible(Profile.verified)
55 |
56 | @parameterized.expand(protected)
57 | def test_protected(self, username, fullname, bio):
58 | self.open_nitter(username)
59 | self.assert_element_visible(Profile.protected)
60 | self.assert_exact_text(fullname, Profile.fullname)
61 | self.assert_exact_text(f'@{username}', Profile.username)
62 |
63 | if len(bio) > 0:
64 | self.assert_text(bio, Profile.bio)
65 | else:
66 | self.assert_element_absent(Profile.bio)
67 |
68 | @parameterized.expand(invalid)
69 | def test_invalid_username(self, username):
70 | self.open_nitter(username)
71 | self.assert_text(f'User "{username}" not found')
72 |
73 | def test_suspended(self):
74 | self.open_nitter('user')
75 | self.assert_text('User "user" has been suspended')
76 |
77 | @parameterized.expand(banner_color)
78 | def test_banner_color(self, username, color):
79 | self.open_nitter(username)
80 | banner = self.find_element(Profile.banner + ' a')
81 | self.assertIn(color, banner.value_of_css_property('background-color'))
82 |
83 | @parameterized.expand(banner_image)
84 | def test_banner_image(self, username, url):
85 | self.open_nitter(username)
86 | banner = self.find_element(Profile.banner + ' img')
87 | self.assertIn(url, banner.get_attribute('src'))
88 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
44 | proc getFavoritesQuery*(name: string): Query =
45 | Query(
46 | kind: favorites,
47 | fromUser: @[name]
48 | )
49 |
50 | proc getReplyQuery*(name: string): Query =
51 | Query(
52 | kind: replies,
53 | fromUser: @[name]
54 | )
55 |
56 | proc genQueryParam*(query: Query): string =
57 | var
58 | filters: seq[string]
59 | param: string
60 |
61 | if query.kind == users:
62 | return query.text
63 |
64 | for i, user in query.fromUser:
65 | param &= &"from:{user} "
66 | if i < query.fromUser.high:
67 | param &= "OR "
68 |
69 | if query.fromUser.len > 0 and query.kind in {posts, media}:
70 | param &= "filter:self_threads OR-filter:replies "
71 |
72 | if "nativeretweets" notin query.excludes:
73 | param &= "include:nativeretweets "
74 |
75 | for f in query.filters:
76 | filters.add "filter:" & f
77 | for e in query.excludes:
78 | if e == "nativeretweets": continue
79 | filters.add "-filter:" & e
80 | for i in query.includes:
81 | filters.add "include:" & i
82 |
83 | result = strip(param & filters.join(&" {query.sep} "))
84 | if query.since.len > 0:
85 | result &= " since:" & query.since
86 | if query.until.len > 0:
87 | result &= " until:" & query.until
88 | if query.near.len > 0:
89 | result &= &" near:\"{query.near}\" within:15mi"
90 | if query.text.len > 0:
91 | if result.len > 0:
92 | result &= " " & query.text
93 | else:
94 | result = query.text
95 |
96 | proc genQueryUrl*(query: Query): string =
97 | if query.kind notin {tweets, users}: return
98 |
99 | var params = @[&"f={query.kind}"]
100 | if query.text.len > 0:
101 | params.add "q=" & encodeUrl(query.text)
102 | for f in query.filters:
103 | params.add &"f-{f}=on"
104 | for e in query.excludes:
105 | params.add &"e-{e}=on"
106 | for i in query.includes.filterIt(it != "nativeretweets"):
107 | params.add &"i-{i}=on"
108 |
109 | if query.since.len > 0:
110 | params.add "since=" & query.since
111 | if query.until.len > 0:
112 | params.add "until=" & query.until
113 | if query.near.len > 0:
114 | params.add "near=" & query.near
115 |
116 | if params.len > 0:
117 | result &= params.join("&")
118 |
--------------------------------------------------------------------------------
/src/experimental/parser/unifiedcard.nim:
--------------------------------------------------------------------------------
1 | import std/[options, tables, strutils, strformat, sugar]
2 | import jsony
3 | import ../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 parseAppDetails(data: ComponentData; card: UnifiedCard; result: var Card) =
31 | let app = card.appStoreData[data.appId][0]
32 |
33 | case app.kind
34 | of androidApp:
35 | result.url = "http://play.google.com/store/apps/details?id=" & app.id
36 | of iPhoneApp, iPadApp:
37 | result.url = "https://itunes.apple.com/app/id" & app.id
38 |
39 | result.text = app.title
40 | result.dest = app.category
41 |
42 | proc parseListDetails(data: ComponentData; result: var Card) =
43 | result.dest = &"List · {data.memberCount} Members"
44 |
45 | proc parseCommunityDetails(data: ComponentData; result: var Card) =
46 | result.dest = &"Community · {data.memberCount} Members"
47 |
48 | proc parseMedia(component: Component; card: UnifiedCard; result: var Card) =
49 | let mediaId =
50 | if component.kind == swipeableMedia:
51 | component.data.mediaList[0].id
52 | else:
53 | component.data.id
54 |
55 | let rMedia = card.mediaEntities[mediaId]
56 | case rMedia.kind:
57 | of photo:
58 | result.kind = summaryLarge
59 | result.image = rMedia.getImageUrl
60 | of video:
61 | let videoInfo = rMedia.videoInfo.get
62 | result.kind = promoVideo
63 | result.video = some Video(
64 | available: true,
65 | thumb: rMedia.getImageUrl,
66 | durationMs: videoInfo.durationMillis,
67 | variants: videoInfo.variants
68 | )
69 | of model3d:
70 | result.title = "Unsupported 3D model ad"
71 |
72 | proc parseUnifiedCard*(json: string): Card =
73 | let card = json.fromJson(UnifiedCard)
74 |
75 | for component in card.componentObjects.values:
76 | case component.kind
77 | of details, communityDetails, twitterListDetails:
78 | component.data.parseDetails(card, result)
79 | of appStoreDetails:
80 | component.data.parseAppDetails(card, result)
81 | of mediaWithDetailsHorizontal:
82 | component.data.parseMediaDetails(card, result)
83 | of media, swipeableMedia:
84 | component.parseMedia(card, result)
85 | of buttonGroup:
86 | discard
87 | of ComponentType.unknown:
88 | echo "ERROR: Unknown component type: ", json
89 |
90 | case component.kind
91 | of twitterListDetails:
92 | component.data.parseListDetails(result)
93 | of communityDetails:
94 | component.data.parseCommunityDetails(result)
95 | else: discard
96 |
--------------------------------------------------------------------------------
/src/consts.nim:
--------------------------------------------------------------------------------
1 | # SPDX-License-Identifier: AGPL-3.0-only
2 | import uri, sequtils
3 |
4 | const
5 | auth* = "Bearer AAAAAAAAAAAAAAAAAAAAAPYXBAAAAAAACLXUNDekMxqa8h%2F40K4moUkGsoc%3DTYfbDKbT3jJPCEVnMYqilB28NHfOPqkca3qaAxGfsyKCs0wRbw"
6 |
7 | api = parseUri("https://api.twitter.com")
8 | activate* = $(api / "1.1/guest/activate.json")
9 |
10 | userShow* = api / "1.1/users/show.json"
11 | photoRail* = api / "1.1/statuses/media_timeline.json"
12 | status* = api / "1.1/statuses/show"
13 | search* = api / "2/search/adaptive.json"
14 |
15 | timelineApi = api / "2/timeline"
16 | timeline* = timelineApi / "profile"
17 | mediaTimeline* = timelineApi / "media"
18 | favorites* = timelineApi / "favorites"
19 | listTimeline* = timelineApi / "list.json"
20 | tweet* = timelineApi / "conversation"
21 |
22 | graphql = api / "graphql"
23 | graphTweet* = graphql / "6lWNh96EXDJCXl05SAtn_g/TweetDetail"
24 | graphUser* = graphql / "7mjxD3-C6BxitPMVQ6w0-Q/UserByScreenName"
25 | graphUserById* = graphql / "I5nvpI91ljifos1Y3Lltyg/UserByRestId"
26 | graphList* = graphql / "JADTh6cjebfgetzvF3tQvQ/List"
27 | graphListBySlug* = graphql / "ErWsz9cObLel1BF-HjuBlA/ListBySlug"
28 | graphListMembers* = graphql / "Ke6urWMeCV2UlKXGRy4sow/ListMembers"
29 |
30 | timelineParams* = {
31 | "include_profile_interstitial_type": "0",
32 | "include_blocking": "0",
33 | "include_blocked_by": "0",
34 | "include_followed_by": "0",
35 | "include_want_retweets": "0",
36 | "include_mute_edge": "0",
37 | "include_can_dm": "0",
38 | "include_can_media_tag": "1",
39 | "skip_status": "1",
40 | "cards_platform": "Web-12",
41 | "include_cards": "1",
42 | "include_composer_source": "false",
43 | "include_reply_count": "1",
44 | "tweet_mode": "extended",
45 | "include_entities": "true",
46 | "include_user_entities": "true",
47 | "include_ext_media_color": "false",
48 | "send_error_codes": "true",
49 | "simple_quoted_tweet": "true",
50 | "include_quote_count": "true"
51 | }.toSeq
52 |
53 | searchParams* = {
54 | "query_source": "typed_query",
55 | "pc": "1",
56 | "spelling_corrections": "1"
57 | }.toSeq
58 | ## top: nothing
59 | ## latest: "tweet_search_mode: live"
60 | ## user: "result_filter: user"
61 | ## photos: "result_filter: photos"
62 | ## videos: "result_filter: videos"
63 |
64 | tweetVariables* = """{
65 | "focalTweetId": "$1",
66 | $2
67 | "includePromotedContent": false,
68 | "withBirdwatchNotes": false,
69 | "withDownvotePerspective": false,
70 | "withReactionsMetadata": false,
71 | "withReactionsPerspective": false,
72 | "withSuperFollowsTweetFields": false,
73 | "withSuperFollowsUserFields": false,
74 | "withVoice": false,
75 | "withV2Timeline": true
76 | }"""
77 |
78 | tweetFeatures* = """{
79 | "graphql_is_translatable_rweb_tweet_is_translatable_enabled": false,
80 | "responsive_web_graphql_timeline_navigation_enabled": false,
81 | "standardized_nudges_misinfo": false,
82 | "verified_phone_label_enabled": false,
83 | "responsive_web_twitter_blue_verified_badge_is_enabled": false,
84 | "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": false,
85 | "view_counts_everywhere_api_enabled": false,
86 | "responsive_web_edit_tweet_api_enabled": false,
87 | "tweetypie_unmention_optimization_enabled": false,
88 | "vibe_api_enabled": false,
89 | "longform_notetweets_consumption_enabled": true,
90 | "responsive_web_text_conversations_enabled": false,
91 | "responsive_web_enhance_cards_enabled": false,
92 | "interactive_text_enabled": false
93 | }"""
94 |
--------------------------------------------------------------------------------
/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 | proc linkUser*(user: User, class=""): VNode =
27 | let
28 | isName = "username" notin class
29 | href = "/" & user.username
30 | nameText = if isName: user.fullname
31 | else: "@" & user.username
32 |
33 | buildHtml(a(href=href, class=class, title=nameText)):
34 | text nameText
35 | if isName and user.verified:
36 | icon "ok", class="verified-icon", title="Verified account"
37 | if isName and user.protected:
38 | text " "
39 | icon "lock", title="Protected account"
40 |
41 | proc linkText*(text: string; class=""): VNode =
42 | let url = if "http" notin text: https & text else: text
43 | buildHtml():
44 | a(href=url, class=class): text text
45 |
46 | proc hiddenField*(name, value: string): VNode =
47 | buildHtml():
48 | input(name=name, style={display: "none"}, value=value)
49 |
50 | proc refererField*(path: string): VNode =
51 | hiddenField("referer", path)
52 |
53 | proc buttonReferer*(action, text, path: string; class=""; `method`="post"): VNode =
54 | buildHtml(form(`method`=`method`, action=action, class=class)):
55 | refererField path
56 | button(`type`="submit"):
57 | text text
58 |
59 | proc genCheckbox*(pref, label: string; state: bool): VNode =
60 | buildHtml(label(class="pref-group checkbox-container")):
61 | text label
62 | if state: input(name=pref, `type`="checkbox", checked="")
63 | else: input(name=pref, `type`="checkbox")
64 | span(class="checkbox")
65 |
66 | proc genInput*(pref, label, state, placeholder: string; class=""; autofocus=true): VNode =
67 | let p = placeholder
68 | buildHtml(tdiv(class=("pref-group pref-input " & class))):
69 | if label.len > 0:
70 | label(`for`=pref): text label
71 | if autofocus and state.len == 0:
72 | input(name=pref, `type`="text", placeholder=p, value=state, autofocus="")
73 | else:
74 | input(name=pref, `type`="text", placeholder=p, value=state)
75 |
76 | proc genSelect*(pref, label, state: string; options: seq[string]): VNode =
77 | buildHtml(tdiv(class="pref-group pref-input")):
78 | label(`for`=pref): text label
79 | select(name=pref):
80 | for opt in options:
81 | if opt == state:
82 | option(value=opt, selected=""): text opt
83 | else:
84 | option(value=opt): text opt
85 |
86 | proc genDate*(pref, state: string): VNode =
87 | buildHtml(span(class="date-input")):
88 | input(name=pref, `type`="date", value=state)
89 | icon "calendar"
90 |
91 | proc genImg*(url: string; class=""): VNode =
92 | buildHtml():
93 | img(src=getPicUrl(url), class=class, alt="")
94 |
95 | proc getTabClass*(query: Query; tab: QueryKind): string =
96 | result = "tab-item"
97 | if query.kind == tab:
98 | result &= " active"
99 |
100 | proc getAvatarClass*(prefs: Prefs): string =
101 | if prefs.squareAvatars:
102 | "avatar"
103 | else:
104 | "avatar round"
105 |
--------------------------------------------------------------------------------
/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 | --icon_text: #{$icon_text};
43 |
44 | --tab: #{$fg_color};
45 | --tab_selected: #{$accent};
46 |
47 | --profile_stat: #{$fg_color};
48 |
49 | background-color: var(--bg_color);
50 | color: var(--fg_color);
51 | font-family: $font_0, $font_1, $font_2, $font_3;
52 | font-size: 14px;
53 | line-height: 1.3;
54 | margin: 0;
55 | }
56 |
57 | * {
58 | outline: unset;
59 | margin: 0;
60 | text-decoration: none;
61 | }
62 |
63 | h1 {
64 | display: inline;
65 | }
66 |
67 | h2, h3 {
68 | font-weight: normal;
69 | }
70 |
71 | p {
72 | margin: 14px 0;
73 | }
74 |
75 | a {
76 | color: var(--accent);
77 |
78 | &:hover {
79 | text-decoration: underline;
80 | }
81 | }
82 |
83 | fieldset {
84 | border: 0;
85 | padding: 0;
86 | margin-top: -0.6em;
87 | }
88 |
89 | legend {
90 | width: 100%;
91 | padding: .6em 0 .3em 0;
92 | border: 0;
93 | font-size: 16px;
94 | font-weight: 600;
95 | border-bottom: 1px solid var(--border_grey);
96 | margin-bottom: 8px;
97 | }
98 |
99 | .preferences .note {
100 | border-top: 1px solid var(--border_grey);
101 | border-bottom: 1px solid var(--border_grey);
102 | padding: 6px 0 8px 0;
103 | margin-bottom: 8px;
104 | margin-top: 16px;
105 | }
106 |
107 | ul {
108 | padding-left: 1.3em;
109 | }
110 |
111 | .container {
112 | display: flex;
113 | flex-wrap: wrap;
114 | box-sizing: border-box;
115 | padding-top: 50px;
116 | margin: auto;
117 | min-height: 100vh;
118 | }
119 |
120 | .icon-container {
121 | display: inline;
122 | }
123 |
124 | .overlay-panel {
125 | max-width: 600px;
126 | width: 100%;
127 | margin: 0 auto;
128 | margin-top: 10px;
129 | background-color: var(--bg_overlays);
130 | padding: 10px 15px;
131 | align-self: start;
132 |
133 | ul {
134 | margin-bottom: 14px;
135 | }
136 |
137 | p {
138 | word-break: break-word;
139 | }
140 | }
141 |
142 | .verified-icon {
143 | color: var(--icon_text);
144 | background-color: var(--verified_blue);
145 | border-radius: 50%;
146 | flex-shrink: 0;
147 | margin: 2px 0 3px 3px;
148 | padding-top: 2px;
149 | height: 12px;
150 | width: 14px;
151 | font-size: 8px;
152 | display: inline-block;
153 | text-align: center;
154 | vertical-align: middle;
155 | }
156 |
157 | @media(max-width: 600px) {
158 | .preferences-container {
159 | max-width: 95vw;
160 | }
161 |
162 | .nav-item, .nav-item .icon-container {
163 | font-size: 16px;
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/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/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 | return Http404
41 |
42 | let hashed = $hash(url)
43 | if request.headers.getOrDefault("If-None-Match") == hashed:
44 | return Http304
45 |
46 | let contentLength =
47 | if res.headers.hasKey("content-length"):
48 | res.headers["content-length", 0]
49 | else:
50 | ""
51 |
52 | let headers = newHttpHeaders({
53 | "Content-Type": res.headers["content-type", 0],
54 | "Content-Length": contentLength,
55 | "Cache-Control": maxAge,
56 | "ETag": hashed
57 | })
58 |
59 | respond(request, headers)
60 |
61 | var (hasValue, data) = (true, "")
62 | while hasValue:
63 | (hasValue, data) = await res.bodyStream.read()
64 | if hasValue:
65 | await request.client.send(data)
66 | data.setLen 0
67 | except HttpRequestError, ProtocolError, OSError:
68 | result = Http404
69 | finally:
70 | client.close()
71 |
72 | template check*(code): untyped =
73 | if code != Http200:
74 | resp code
75 | else:
76 | enableRawMode()
77 | break route
78 |
79 | proc decoded*(req: jester.Request; index: int): string =
80 | let
81 | based = req.matches[0].len > 1
82 | encoded = req.matches[index]
83 | if based: decode(encoded)
84 | else: decodeUrl(encoded)
85 |
86 | proc createMediaRouter*(cfg: Config) =
87 | router media:
88 | get "/pic/?":
89 | resp Http404
90 |
91 | get re"^\/pic\/orig\/(enc)?\/?(.+)":
92 | var url = decoded(request, 1)
93 | if "twimg.com" notin url:
94 | url.insert(twimg)
95 | if not url.startsWith(https):
96 | url.insert(https)
97 | url.add("?name=orig")
98 |
99 | let uri = parseUri(url)
100 | cond isTwitterUrl(uri) == true
101 |
102 | let code = await proxyMedia(request, url)
103 | check code
104 |
105 | get re"^\/pic\/(enc)?\/?(.+)":
106 | var url = decoded(request, 1)
107 | if "twimg.com" notin url:
108 | url.insert(twimg)
109 | if not url.startsWith(https):
110 | url.insert(https)
111 |
112 | let uri = parseUri(url)
113 | cond isTwitterUrl(uri) == true
114 |
115 | let code = await proxyMedia(request, url)
116 | check code
117 |
118 | get re"^\/video\/(enc)?\/?(.+)\/(.+)$":
119 | let url = decoded(request, 2)
120 | cond "http" in url
121 |
122 | if getHmac(url) != request.matches[1]:
123 | resp showError("Failed to verify signature", cfg)
124 |
125 | if ".mp4" in url or ".ts" in url or ".m4s" in url:
126 | let code = await proxyMedia(request, url)
127 | check code
128 |
129 | var content: string
130 | if ".vmap" in url:
131 | let m3u8 = getM3u8Url(await safeFetch(url))
132 | if m3u8.len > 0:
133 | content = await safeFetch(url)
134 | else:
135 | resp Http404
136 |
137 | if ".m3u8" in url:
138 | let vid = await safeFetch(url)
139 | content = proxifyVideo(vid, cookiePref(proxyVideos))
140 |
141 | resp content, m3u8Mime
142 |
--------------------------------------------------------------------------------
/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/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; cfg: Config; 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, cfg, prefs, path, profile.pinned)
120 |
--------------------------------------------------------------------------------
/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/views/timeline.nim:
--------------------------------------------------------------------------------
1 | # SPDX-License-Identifier: AGPL-3.0-only
2 | import strutils, strformat, sequtils, 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: seq[Tweet]; 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 | let show = i == thread.high and sortedThread[0].id != tweet.threadId
47 | let header = if tweet.pinned or tweet.retweet.isSome: "with-header " else: ""
48 | renderTweet(tweet, prefs, path, class=(header & "thread"),
49 | index=i, last=(i == thread.high), showThread=show)
50 |
51 | proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] =
52 | result = @[it]
53 | if it.retweet.isSome or it.replyId in threads: return
54 | for t in tweets:
55 | if t.id == result[0].replyId:
56 | result.insert t
57 | elif t.replyId == result[0].id:
58 | result.add t
59 |
60 | proc renderUser(user: User; prefs: Prefs): VNode =
61 | buildHtml(tdiv(class="timeline-item")):
62 | a(class="tweet-link", href=("/" & user.username))
63 | tdiv(class="tweet-body profile-result"):
64 | tdiv(class="tweet-header"):
65 | a(class="tweet-avatar", href=("/" & user.username)):
66 | genImg(user.getUserPic("_bigger"), class=prefs.getAvatarClass)
67 |
68 | tdiv(class="tweet-name-row"):
69 | tdiv(class="fullname-and-username"):
70 | linkUser(user, class="fullname")
71 | linkUser(user, class="username")
72 |
73 | tdiv(class="tweet-content media-body", dir="auto"):
74 | verbatim replaceUrls(user.bio, prefs)
75 |
76 | proc renderTimelineUsers*(results: Result[User]; prefs: Prefs; path=""): VNode =
77 | buildHtml(tdiv(class="timeline")):
78 | if not results.beginning:
79 | renderNewer(results.query, path)
80 |
81 | if results.content.len > 0:
82 | for user in results.content:
83 | renderUser(user, prefs)
84 | if results.bottom.len > 0:
85 | renderMore(results.query, results.bottom)
86 | renderToTop()
87 | elif results.beginning:
88 | renderNoneFound()
89 | else:
90 | renderNoMore()
91 |
92 | proc renderTimelineTweets*(results: Result[Tweet]; prefs: Prefs; path: string;
93 | pinned=none(Tweet)): VNode =
94 | buildHtml(tdiv(class="timeline")):
95 | if not results.beginning:
96 | renderNewer(results.query, parseUri(path).path)
97 |
98 | if not prefs.hidePins and pinned.isSome:
99 | let tweet = get pinned
100 | renderTweet(tweet, prefs, path, showThread=tweet.hasThread)
101 |
102 | if results.content.len == 0:
103 | if not results.beginning:
104 | renderNoMore()
105 | else:
106 | renderNoneFound()
107 | else:
108 | var
109 | threads: seq[int64]
110 | retweets: seq[int64]
111 |
112 | for tweet in results.content:
113 | let rt = if tweet.retweet.isSome: get(tweet.retweet).id else: 0
114 |
115 | if tweet.id in threads or rt in retweets or tweet.id in retweets or
116 | tweet.pinned and prefs.hidePins: continue
117 |
118 | let thread = results.content.threadFilter(threads, tweet)
119 | if thread.len < 2:
120 | var hasThread = tweet.hasThread
121 | if rt != 0:
122 | retweets &= rt
123 | hasThread = get(tweet.retweet).hasThread
124 | renderTweet(tweet, prefs, path, showThread=hasThread)
125 | else:
126 | renderThread(thread, prefs, path)
127 | threads &= thread.mapIt(it.id)
128 |
129 | renderMore(results.query, results.bottom)
130 | renderToTop()
131 |
--------------------------------------------------------------------------------
/src/apiutils.nim:
--------------------------------------------------------------------------------
1 | # SPDX-License-Identifier: AGPL-3.0-only
2 | import httpclient, asyncdispatch, options, strutils, uri
3 | import jsony, packedjson, zippy
4 | import types, tokens, consts, parserutils, http_pool
5 | import experimental/types/common
6 | import config
7 |
8 | const
9 | rlRemaining = "x-rate-limit-remaining"
10 | rlReset = "x-rate-limit-reset"
11 |
12 | var pool: HttpPool
13 |
14 | proc genParams*(pars: openArray[(string, string)] = @[]; cursor="";
15 | count="20"; ext=true): seq[(string, string)] =
16 | result = timelineParams
17 | for p in pars:
18 | result &= p
19 | if ext:
20 | result &= ("ext", "mediaStats")
21 | result &= ("include_ext_alt_text", "true")
22 | result &= ("include_ext_media_availability", "true")
23 | if count.len > 0:
24 | result &= ("count", count)
25 | if cursor.len > 0:
26 | # The raw cursor often has plus signs, which sometimes get turned into spaces,
27 | # so we need to turn them back into a plus
28 | if " " in cursor:
29 | result &= ("cursor", cursor.replace(" ", "+"))
30 | else:
31 | result &= ("cursor", cursor)
32 |
33 | proc genHeaders*(token: Token = nil): HttpHeaders =
34 | result = newHttpHeaders({
35 | "connection": "keep-alive",
36 | "authorization": auth,
37 | "content-type": "application/json",
38 | "x-guest-token": if token == nil: "" else: token.tok,
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 updateToken() =
48 | if api != Api.search and resp.headers.hasKey(rlRemaining):
49 | let
50 | remaining = parseInt(resp.headers[rlRemaining])
51 | reset = parseInt(resp.headers[rlReset])
52 | token.setRateLimit(api, remaining, reset)
53 |
54 | template fetchImpl(result, additional_headers, fetchBody) {.dirty.} =
55 | once:
56 | pool = HttpPool()
57 |
58 | var token = await getToken(api)
59 | if token.tok.len == 0:
60 | raise rateLimitError()
61 |
62 | try:
63 | var resp: AsyncResponse
64 | var headers = genHeaders(token)
65 | for key, value in additional_headers.pairs():
66 | headers.add(key, value)
67 | pool.use(headers):
68 | template getContent =
69 | resp = await c.get($url)
70 | result = await resp.body
71 |
72 | getContent()
73 |
74 | # Twitter randomly returns 401 errors with an empty body quite often.
75 | # Retrying the request usually works.
76 | if resp.status == "401 Unauthorized" and result.len == 0:
77 | getContent()
78 |
79 | if resp.status == $Http429:
80 | raise rateLimitError()
81 |
82 | if resp.status == $Http503:
83 | badClient = true
84 | raise newException(InternalError, result)
85 |
86 | if result.len > 0:
87 | if resp.headers.getOrDefault("content-encoding") == "gzip":
88 | result = uncompress(result, dfGzip)
89 | else:
90 | echo "non-gzip body, url: ", url, ", body: ", result
91 |
92 | fetchBody
93 |
94 | release(token, used=true)
95 |
96 | if resp.status == $Http400:
97 | raise newException(InternalError, $url)
98 | except InternalError as e:
99 | raise e
100 | except Exception as e:
101 | echo "error: ", e.name, ", msg: ", e.msg, ", token: ", token[], ", url: ", url
102 | if "length" notin e.msg and "descriptor" notin e.msg:
103 | release(token, invalid=true)
104 | raise rateLimitError()
105 |
106 | proc fetch*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[JsonNode] {.async.} =
107 |
108 | if len(cfg.cookieHeader) != 0:
109 | additional_headers.add("Cookie", cfg.cookieHeader)
110 | if len(cfg.xCsrfToken) != 0:
111 | additional_headers.add("x-csrf-token", cfg.xCsrfToken)
112 |
113 | var body: string
114 | fetchImpl(body, additional_headers):
115 | if body.startsWith('{') or body.startsWith('['):
116 | result = parseJson(body)
117 | else:
118 | echo resp.status, ": ", body, " --- url: ", url
119 | result = newJNull()
120 |
121 | updateToken()
122 |
123 | let error = result.getError
124 | if error in {invalidToken, badToken}:
125 | echo "fetch error: ", result.getError
126 | release(token, invalid=true)
127 | raise rateLimitError()
128 |
129 | proc fetchRaw*(url: Uri; api: Api; additional_headers: HttpHeaders = newHttpHeaders()): Future[string] {.async.} =
130 | fetchImpl(result, additional_headers):
131 | if not (result.startsWith('{') or result.startsWith('[')):
132 | echo resp.status, ": ", result, " --- url: ", url
133 | result.setLen(0)
134 |
135 | updateToken()
136 |
137 | if result.startsWith("{\"errors"):
138 | let errors = result.fromJson(Errors)
139 | if errors in {invalidToken, badToken}:
140 | echo "fetch error: ", errors
141 | release(token, invalid=true)
142 | raise rateLimitError()
143 |
--------------------------------------------------------------------------------
/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; cfg: Config): 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 | if len(cfg.xCsrfToken) != 0 and len(cfg.cookieHeader) != 0:
42 | li(class=query.getTabClass(favorites)):
43 | a(href=(link & "/favorites")): text "Likes"
44 | li(class=query.getTabClass(tweets)):
45 | a(href=(link & "/search")): text "Search"
46 |
47 | proc renderSearchTabs*(query: Query): VNode =
48 | var q = query
49 | buildHtml(ul(class="tab")):
50 | li(class=query.getTabClass(tweets)):
51 | q.kind = tweets
52 | a(href=("?" & genQueryUrl(q))): text "Tweets"
53 | li(class=query.getTabClass(users)):
54 | q.kind = users
55 | a(href=("?" & genQueryUrl(q))): text "Users"
56 |
57 | proc isPanelOpen(q: Query): bool =
58 | q.fromUser.len == 0 and (q.filters.len > 0 or q.excludes.len > 0 or
59 | @[q.near, q.until, q.since].anyIt(it.len > 0))
60 |
61 | proc renderSearchPanel*(query: Query): VNode =
62 | let user = query.fromUser.join(",")
63 | let action = if user.len > 0: &"/{user}/search" else: "/search"
64 | buildHtml(form(`method`="get", action=action,
65 | class="search-field", autocomplete="off")):
66 | hiddenField("f", "tweets")
67 | genInput("q", "", query.text, "Enter search...", class="pref-inline")
68 | button(`type`="submit"): icon "search"
69 | if isPanelOpen(query):
70 | input(id="search-panel-toggle", `type`="checkbox", checked="")
71 | else:
72 | input(id="search-panel-toggle", `type`="checkbox")
73 | label(`for`="search-panel-toggle"):
74 | icon "down"
75 | tdiv(class="search-panel"):
76 | for f in @["filter", "exclude"]:
77 | span(class="search-title"): text capitalize(f)
78 | tdiv(class="search-toggles"):
79 | for k, v in toggles:
80 | let state =
81 | if f == "filter": k in query.filters
82 | else: k in query.excludes
83 | genCheckbox(&"{f[0]}-{k}", v, state)
84 |
85 | tdiv(class="search-row"):
86 | tdiv:
87 | span(class="search-title"): text "Time range"
88 | tdiv(class="date-range"):
89 | genDate("since", query.since)
90 | span(class="search-title"): text "-"
91 | genDate("until", query.until)
92 | tdiv:
93 | span(class="search-title"): text "Near"
94 | genInput("near", "", query.near, "Location...", autofocus=false)
95 |
96 | proc renderTweetSearch*(results: Result[Tweet]; cfg: Config; prefs: Prefs; path: string;
97 | pinned=none(Tweet)): VNode =
98 | let query = results.query
99 | buildHtml(tdiv(class="timeline-container")):
100 | if query.fromUser.len > 1:
101 | tdiv(class="timeline-header"):
102 | text query.fromUser.join(" | ")
103 |
104 | if query.fromUser.len > 0:
105 | renderProfileTabs(query, query.fromUser.join(","), cfg)
106 |
107 | if query.fromUser.len == 0 or query.kind == tweets:
108 | tdiv(class="timeline-header"):
109 | renderSearchPanel(query)
110 |
111 | if query.fromUser.len == 0:
112 | renderSearchTabs(query)
113 |
114 | renderTimelineTweets(results, prefs, path, pinned)
115 |
116 | proc renderUserSearch*(results: Result[User]; prefs: Prefs): VNode =
117 | buildHtml(tdiv(class="timeline-container")):
118 | tdiv(class="timeline-header"):
119 | form(`method`="get", action="/search", class="search-field", autocomplete="off"):
120 | hiddenField("f", "users")
121 | genInput("q", "", results.query.text, "Enter username...", class="pref-inline")
122 | button(`type`="submit"): icon "search"
123 |
124 | renderSearchTabs(results.query)
125 | renderTimelineUsers(results, prefs)
126 |
--------------------------------------------------------------------------------
/tests/test_card.py:
--------------------------------------------------------------------------------
1 | from base import BaseTestCase, Card, Conversation
2 | from parameterized import parameterized
3 |
4 |
5 | card = [
6 | ['Thom_Wolf/status/1122466524860702729',
7 | 'facebookresearch/fairseq',
8 | 'Facebook AI Research Sequence-to-Sequence Toolkit written in Python. - GitHub - facebookresearch/fairseq: Facebook AI Research Sequence-to-Sequence Toolkit written in Python.',
9 | 'github.com', True],
10 |
11 | ['nim_lang/status/1136652293510717440',
12 | 'Version 0.20.0 released',
13 | '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!',
14 | 'nim-lang.org', True],
15 |
16 | ['voidtarget/status/1094632512926605312',
17 | 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too)',
18 | 'Basic OBS Studio plugin, written in nim, supporting C++ (C fine too) - obsplugin.nim',
19 | 'gist.github.com', True],
20 |
21 | ['FluentAI/status/1116417904831029248',
22 | 'Amazon’s Alexa isn’t just AI — thousands of humans are listening',
23 | 'One of the only ways to improve Alexa is to have human beings check it for errors',
24 | 'theverge.com', True]
25 | ]
26 |
27 | no_thumb = [
28 | ['brent_p/status/1088857328680488961',
29 | 'Hts Nim Sugar',
30 | '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...',
31 | 'brentp.github.io'],
32 |
33 | ['voidtarget/status/1133028231672582145',
34 | 'sinkingsugar/nimqt-example',
35 | 'A sample of a Qt app written using mostly nim. Contribute to sinkingsugar/nimqt-example development by creating an account on GitHub.',
36 | 'github.com'],
37 |
38 | ['mobile_test/status/490378953744318464',
39 | 'Nantasket Beach',
40 | 'Explore this photo titled Nantasket Beach by Ben Sandofsky (@sandofsky) on 500px',
41 | '500px.com'],
42 |
43 | ['nim_lang/status/1082989146040340480',
44 | 'Nim in 2018: A short recap',
45 | 'Posted by u/miran1 - 36 votes and 46 comments',
46 | 'reddit.com']
47 | ]
48 |
49 | playable = [
50 | ['nim_lang/status/1118234460904919042',
51 | 'Nim development blog 2019-03',
52 | 'Arne (aka Krux02)* debugging: * improved nim-gdb, $ works, framefilter * alias for --debugger:native: -g* bugs: * forwarding of .pure. * sizeof union* fe...',
53 | 'youtube.com'],
54 |
55 | ['nim_lang/status/1121090879823986688',
56 | 'Nim - First natively compiled language w/ hot code-reloading at...',
57 | '#nim #c++ #ACCUConfNim is a statically typed systems and applications programming language which offers perhaps some of the most powerful metaprogramming cap...',
58 | 'youtube.com']
59 | ]
60 |
61 | # promo = [
62 | # ['BangOlufsen/status/1145698701517754368',
63 | # 'Upgrade your journey', '',
64 | # 'www.bang-olufsen.com'],
65 |
66 | # ['BangOlufsen/status/1154934429900406784',
67 | # 'Learn more about Beosound Shape', '',
68 | # 'www.bang-olufsen.com']
69 | # ]
70 |
71 |
72 | class CardTest(BaseTestCase):
73 | @parameterized.expand(card)
74 | def test_card(self, tweet, title, description, destination, large):
75 | self.open_nitter(tweet)
76 | c = Card(Conversation.main + " ")
77 | self.assert_text(title, c.title)
78 | self.assert_text(destination, c.destination)
79 | self.assertIn('_img', self.get_image_url(c.image + ' img'))
80 | if len(description) > 0:
81 | self.assert_text(description, c.description)
82 | if large:
83 | self.assert_element_visible('.card.large')
84 | else:
85 | self.assert_element_not_visible('.card.large')
86 |
87 | @parameterized.expand(no_thumb)
88 | def test_card_no_thumb(self, tweet, title, description, destination):
89 | self.open_nitter(tweet)
90 | c = Card(Conversation.main + " ")
91 | self.assert_text(title, c.title)
92 | self.assert_text(destination, c.destination)
93 | if len(description) > 0:
94 | self.assert_text(description, c.description)
95 |
96 | @parameterized.expand(playable)
97 | def test_card_playable(self, tweet, title, description, destination):
98 | self.open_nitter(tweet)
99 | c = Card(Conversation.main + " ")
100 | self.assert_text(title, c.title)
101 | self.assert_text(destination, c.destination)
102 | self.assertIn('_img', self.get_image_url(c.image + ' img'))
103 | self.assert_element_visible('.card-overlay')
104 | if len(description) > 0:
105 | self.assert_text(description, c.description)
106 |
107 | # @parameterized.expand(promo)
108 | # def test_card_promo(self, tweet, title, description, destination):
109 | # self.open_nitter(tweet)
110 | # c = Card(Conversation.main + " ")
111 | # self.assert_text(title, c.title)
112 | # self.assert_text(destination, c.destination)
113 | # self.assert_element_visible('.video-overlay')
114 | # if len(description) > 0:
115 | # self.assert_text(description, c.description)
116 |
--------------------------------------------------------------------------------
/src/tokens.nim:
--------------------------------------------------------------------------------
1 | # SPDX-License-Identifier: AGPL-3.0-only
2 | import asyncdispatch, httpclient, times, sequtils, json, random
3 | import strutils, tables
4 | import zippy
5 | import types, consts, http_pool
6 |
7 | const
8 | maxConcurrentReqs = 5 # max requests at a time per token, to avoid race conditions
9 | maxLastUse = 1.hours # if a token is unused for 60 minutes, it expires
10 | maxAge = 2.hours + 55.minutes # tokens expire after 3 hours
11 | failDelay = initDuration(minutes=30)
12 |
13 | var
14 | clientPool: HttpPool
15 | tokenPool: seq[Token]
16 | lastFailed: Time
17 | enableLogging = false
18 |
19 | template log(str) =
20 | if enableLogging: echo "[tokens] ", str
21 |
22 | proc getPoolJson*(): JsonNode =
23 | var
24 | list = newJObject()
25 | totalReqs = 0
26 | totalPending = 0
27 | reqsPerApi: Table[string, int]
28 |
29 | for token in tokenPool:
30 | totalPending.inc(token.pending)
31 | list[token.tok] = %*{
32 | "apis": newJObject(),
33 | "pending": token.pending,
34 | "init": $token.init,
35 | "lastUse": $token.lastUse
36 | }
37 |
38 | for api in token.apis.keys:
39 | list[token.tok]["apis"][$api] = %token.apis[api]
40 |
41 | let
42 | maxReqs =
43 | case api
44 | of Api.listMembers, Api.listBySlug, Api.list,
45 | Api.userRestId, Api.userScreenName, Api.tweetDetail: 500
46 | of Api.timeline: 187
47 | else: 180
48 | reqs = maxReqs - token.apis[api].remaining
49 |
50 | reqsPerApi[$api] = reqsPerApi.getOrDefault($api, 0) + reqs
51 | totalReqs.inc(reqs)
52 |
53 | return %*{
54 | "amount": tokenPool.len,
55 | "requests": totalReqs,
56 | "pending": totalPending,
57 | "apis": reqsPerApi,
58 | "tokens": list
59 | }
60 |
61 | proc rateLimitError*(): ref RateLimitError =
62 | newException(RateLimitError, "rate limited")
63 |
64 | proc fetchToken(): Future[Token] {.async.} =
65 | if getTime() - lastFailed < failDelay:
66 | raise rateLimitError()
67 |
68 | let headers = newHttpHeaders({
69 | "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
70 | "accept-encoding": "gzip",
71 | "accept-language": "en-US,en;q=0.5",
72 | "connection": "keep-alive",
73 | "authorization": auth
74 | })
75 |
76 | try:
77 | let
78 | resp = clientPool.use(headers): await c.postContent(activate)
79 | tokNode = parseJson(uncompress(resp))["guest_token"]
80 | tok = tokNode.getStr($(tokNode.getInt))
81 | time = getTime()
82 |
83 | return Token(tok: tok, init: time, lastUse: time)
84 | except Exception as e:
85 | echo "[tokens] fetching token failed: ", e.msg
86 | if "Try again" notin e.msg:
87 | echo "[tokens] fetching tokens paused, resuming in 30 minutes"
88 | lastFailed = getTime()
89 |
90 | proc expired(token: Token): bool =
91 | let time = getTime()
92 | token.init < time - maxAge or token.lastUse < time - maxLastUse
93 |
94 | proc isLimited(token: Token; api: Api): bool =
95 | if token.isNil or token.expired:
96 | return true
97 |
98 | if api in token.apis:
99 | let limit = token.apis[api]
100 | return (limit.remaining <= 10 and limit.reset > epochTime().int)
101 | else:
102 | return false
103 |
104 | proc isReady(token: Token; api: Api): bool =
105 | not (token.isNil or token.pending > maxConcurrentReqs or token.isLimited(api))
106 |
107 | proc release*(token: Token; used=false; invalid=false) =
108 | if token.isNil: return
109 | if invalid or token.expired:
110 | if invalid: log "discarding invalid token"
111 | elif token.expired: log "discarding expired token"
112 |
113 | let idx = tokenPool.find(token)
114 | if idx > -1: tokenPool.delete(idx)
115 | elif used:
116 | dec token.pending
117 | token.lastUse = getTime()
118 |
119 | proc getToken*(api: Api): Future[Token] {.async.} =
120 | for i in 0 ..< tokenPool.len:
121 | if result.isReady(api): break
122 | release(result)
123 | result = tokenPool.sample()
124 |
125 | if not result.isReady(api):
126 | release(result)
127 | result = await fetchToken()
128 | log "added new token to pool"
129 | tokenPool.add result
130 |
131 | if not result.isNil:
132 | inc result.pending
133 | else:
134 | raise rateLimitError()
135 |
136 | proc setRateLimit*(token: Token; api: Api; remaining, reset: int) =
137 | # avoid undefined behavior in race conditions
138 | if api in token.apis:
139 | let limit = token.apis[api]
140 | if limit.reset >= reset and limit.remaining < remaining:
141 | return
142 |
143 | token.apis[api] = RateLimit(remaining: remaining, reset: reset)
144 |
145 | proc poolTokens*(amount: int) {.async.} =
146 | var futs: seq[Future[Token]]
147 | for i in 0 ..< amount:
148 | futs.add fetchToken()
149 |
150 | for token in futs:
151 | var newToken: Token
152 |
153 | try: newToken = await token
154 | except: discard
155 |
156 | if not newToken.isNil:
157 | log "added new token to pool"
158 | tokenPool.add newToken
159 |
160 | proc initTokenPool*(cfg: Config) {.async.} =
161 | clientPool = HttpPool()
162 | enableLogging = cfg.enableDebug
163 |
164 | while true:
165 | if tokenPool.countIt(not it.isLimited(Api.timeline)) < cfg.minTokens:
166 | await poolTokens(min(4, cfg.minTokens - tokenPool.len))
167 | await sleepAsync(2000)
168 |
--------------------------------------------------------------------------------
/tests/test_tweet.py:
--------------------------------------------------------------------------------
1 | from base import BaseTestCase, Tweet, get_timeline_tweet
2 | from parameterized import parameterized
3 |
4 | # image = tweet + 'div.attachments.media-body > div > div > a > div > img'
5 | # self.assert_true(self.get_image_url(image).split('/')[0] == 'http')
6 |
7 | timeline = [
8 | [1, 'Test account', 'mobile_test', '10 Aug 2016', '763483571793174528',
9 | '.'],
10 |
11 | [3, 'Test account', 'mobile_test', '3 Mar 2016', '705522133443571712',
12 | 'LIVE on #Periscope pscp.tv/w/aadiTzF6dkVOTXZSbX…'],
13 |
14 | [6, 'mobile test 2', 'mobile_test_2', '1 Oct 2014', '517449200045277184',
15 | 'Testing. One two three four. Test.']
16 | ]
17 |
18 | status = [
19 | [20, 'jack', 'jack', '21 Mar 2006', 'just setting up my twttr'],
20 | [134849778302464000, 'The Twoffice', 'TheTwoffice', '11 Nov 2011', 'test'],
21 | [105685475985080322, 'The Twoffice', 'TheTwoffice', '22 Aug 2011', 'regular tweet'],
22 | [572593440719912960, 'Test account', 'mobile_test', '3 Mar 2015', 'testing test']
23 | ]
24 |
25 | invalid = [
26 | ['mobile_test/status/120938109238'],
27 | ['TheTwoffice/status/8931928312']
28 | ]
29 |
30 | multiline = [
31 | [400897186990284800, 'mobile_test_3',
32 | """
33 | ♔
34 | KEEP
35 | CALM
36 | AND
37 | CLICHÉ
38 | ON"""]
39 | ]
40 |
41 | link = [
42 | ['nim_lang/status/1110499584852353024', [
43 | 'nim-lang.org/araq/ownedrefs.…',
44 | 'news.ycombinator.com/item?id…',
45 | 'teddit.net/r/programming…'
46 | ]],
47 | ['nim_lang/status/1125887775151140864', [
48 | 'en.wikipedia.org/wiki/Nim_(p…'
49 | ]],
50 | ['hiankun_taioan/status/1086916335215341570', [
51 | '(hackernoon.com/interview-wit…)'
52 | ]],
53 | ['archillinks/status/1146302618223951873', [
54 | 'flickr.com/photos/87101284@N…',
55 | 'hisafoto.tumblr.com/post/176…'
56 | ]],
57 | ['archillinks/status/1146292551936335873', [
58 | 'flickr.com/photos/michaelrye…',
59 | 'furtho.tumblr.com/post/16618…'
60 | ]]
61 | ]
62 |
63 | username = [
64 | ['Bountysource/status/1094803522053320705', ['nim_lang']],
65 | ['leereilly/status/1058464250098704385', ['godotengine', 'unity3d', 'nim_lang']]
66 | ]
67 |
68 | emoji = [
69 | ['Tesla/status/1134850442511257600', '🌈❤️🧡💛💚💙💜']
70 | ]
71 |
72 | retweet = [
73 | [7, 'mobile_test_2', 'mobile test 2', 'Test account', '@mobile_test', '1234'],
74 | [3, 'mobile_test_8', 'mobile test 8', 'jack', '@jack', 'twttr']
75 | ]
76 |
77 | reply = [
78 | ['mobile_test/with_replies', 15]
79 | ]
80 |
81 |
82 | class TweetTest(BaseTestCase):
83 | @parameterized.expand(timeline)
84 | def test_timeline(self, index, fullname, username, date, tid, text):
85 | self.open_nitter(username)
86 | tweet = get_timeline_tweet(index)
87 | self.assert_exact_text(fullname, tweet.fullname)
88 | self.assert_exact_text('@' + username, tweet.username)
89 | self.assert_exact_text(date, tweet.date)
90 | self.assert_text(text, tweet.text)
91 | permalink = self.find_element(tweet.date + ' a')
92 | self.assertIn(tid, permalink.get_attribute('href'))
93 |
94 | @parameterized.expand(status)
95 | def test_status(self, tid, fullname, username, date, text):
96 | tweet = Tweet()
97 | self.open_nitter(f'{username}/status/{tid}')
98 | self.assert_exact_text(fullname, tweet.fullname)
99 | self.assert_exact_text('@' + username, tweet.username)
100 | self.assert_exact_text(date, tweet.date)
101 | self.assert_text(text, tweet.text)
102 |
103 | @parameterized.expand(multiline)
104 | def test_multiline_formatting(self, tid, username, text):
105 | self.open_nitter(f'{username}/status/{tid}')
106 | self.assert_text(text.strip('\n'), '.main-tweet')
107 |
108 | @parameterized.expand(emoji)
109 | def test_emoji(self, tweet, text):
110 | self.open_nitter(tweet)
111 | self.assert_text(text, '.main-tweet')
112 |
113 | @parameterized.expand(link)
114 | def test_link(self, tweet, links):
115 | self.open_nitter(tweet)
116 | for link in links:
117 | self.assert_text(link, '.main-tweet')
118 |
119 | @parameterized.expand(username)
120 | def test_username(self, tweet, usernames):
121 | self.open_nitter(tweet)
122 | for un in usernames:
123 | link = self.find_link_text(f'@{un}')
124 | self.assertIn(f'/{un}', link.get_property('href'))
125 |
126 | @parameterized.expand(retweet)
127 | def test_retweet(self, index, url, retweet_by, fullname, username, text):
128 | self.open_nitter(url)
129 | tweet = get_timeline_tweet(index)
130 | self.assert_text(f'{retweet_by} retweeted', tweet.retweet)
131 | self.assert_text(text, tweet.text)
132 | self.assert_exact_text(fullname, tweet.fullname)
133 | self.assert_exact_text(username, tweet.username)
134 |
135 | @parameterized.expand(invalid)
136 | def test_invalid_id(self, tweet):
137 | self.open_nitter(tweet)
138 | self.assert_text('Tweet not found', '.error-panel')
139 |
140 | @parameterized.expand(reply)
141 | def test_thread(self, tweet, num):
142 | self.open_nitter(tweet)
143 | thread = self.find_element(f'.timeline > div:nth-child({num})')
144 | self.assertIn(thread.get_attribute('class'), 'thread-line')
145 |
--------------------------------------------------------------------------------
/src/routes/rss.nim:
--------------------------------------------------------------------------------
1 | # SPDX-License-Identifier: AGPL-3.0-only
2 | import asyncdispatch, tables, times, hashes, uri
3 |
4 | import jester
5 |
6 | import router_utils, timeline
7 | import ../query
8 |
9 | include "../views/rss.nimf"
10 |
11 | export times, hashes
12 |
13 | proc redisKey*(page, name, cursor: string): string =
14 | result = page & ":" & name
15 | if cursor.len > 0:
16 | result &= ":" & cursor
17 |
18 | proc timelineRss*(req: Request; cfg: Config; query: Query): Future[Rss] {.async.} =
19 | var profile: Profile
20 | let
21 | name = req.params.getOrDefault("name")
22 | after = getCursor(req)
23 | names = getNames(name)
24 |
25 | if names.len == 1:
26 | profile = await fetchProfile(after, query, cfg, skipRail=true, skipPinned=true)
27 | else:
28 | var q = query
29 | q.fromUser = names
30 | profile = Profile(
31 | tweets: await getSearch[Tweet](q, after),
32 | # this is kinda dumb
33 | user: User(
34 | username: name,
35 | fullname: names.join(" | "),
36 | userpic: "https://abs.twimg.com/sticky/default_profile_images/default_profile.png"
37 | )
38 | )
39 |
40 | if profile.user.suspended:
41 | return Rss(feed: profile.user.username, cursor: "suspended")
42 |
43 | if profile.user.fullname.len > 0:
44 | let rss = renderTimelineRss(profile, cfg, multi=(names.len > 1))
45 | return Rss(feed: rss, cursor: profile.tweets.bottom)
46 |
47 | template respRss*(rss, page) =
48 | if rss.cursor.len == 0:
49 | let info = case page
50 | of "User": " \"" & @"name" & "\" "
51 | of "List": " \"" & @"id" & "\" "
52 | else: " "
53 |
54 | resp Http404, showError(page & info & "not found", cfg)
55 | elif rss.cursor.len == 9 and rss.cursor == "suspended":
56 | resp Http404, showError(getSuspended(@"name"), cfg)
57 |
58 | let headers = {"Content-Type": "application/rss+xml; charset=utf-8",
59 | "Min-Id": rss.cursor}
60 | resp Http200, headers, rss.feed
61 |
62 | proc createRssRouter*(cfg: Config) =
63 | router rss:
64 | get "/search/rss":
65 | cond cfg.enableRss
66 | if @"q".len > 200:
67 | resp Http400, showError("Search input too long.", cfg)
68 |
69 | let query = initQuery(params(request))
70 | if query.kind != tweets:
71 | resp Http400, showError("Only Tweet searches are allowed for RSS feeds.", cfg)
72 |
73 | let
74 | cursor = getCursor()
75 | key = redisKey("search", $hash(genQueryUrl(query)), cursor)
76 |
77 | var rss = await getCachedRss(key)
78 | if rss.cursor.len > 0:
79 | respRss(rss, "Search")
80 |
81 | let tweets = await getSearch[Tweet](query, cursor)
82 | rss.cursor = tweets.bottom
83 | rss.feed = renderSearchRss(tweets.content, query.text, genQueryUrl(query), cfg)
84 |
85 | await cacheRss(key, rss)
86 | respRss(rss, "Search")
87 |
88 | get "/@name/rss":
89 | cond cfg.enableRss
90 | cond '.' notin @"name"
91 | let
92 | name = @"name"
93 | key = redisKey("twitter", name, getCursor())
94 |
95 | var rss = await getCachedRss(key)
96 | if rss.cursor.len > 0:
97 | respRss(rss, "User")
98 |
99 | rss = await timelineRss(request, cfg, Query(fromUser: @[name]))
100 |
101 | await cacheRss(key, rss)
102 | respRss(rss, "User")
103 |
104 | get "/@name/@tab/rss":
105 | cond cfg.enableRss
106 | cond '.' notin @"name"
107 | cond @"tab" in ["with_replies", "media", "favorites", "search"]
108 | let
109 | name = @"name"
110 | tab = @"tab"
111 | query =
112 | case tab
113 | of "with_replies": getReplyQuery(name)
114 | of "media": getMediaQuery(name)
115 | of "favorites": getFavoritesQuery(name)
116 | of "search": initQuery(params(request), name=name)
117 | else: Query(fromUser: @[name])
118 |
119 | let searchKey = if tab != "search": ""
120 | else: ":" & $hash(genQueryUrl(query))
121 |
122 | let key = redisKey(tab, name & searchKey, getCursor())
123 |
124 | var rss = await getCachedRss(key)
125 | if rss.cursor.len > 0:
126 | respRss(rss, "User")
127 |
128 | rss = await timelineRss(request, cfg, query)
129 |
130 | await cacheRss(key, rss)
131 | respRss(rss, "User")
132 |
133 | get "/@name/lists/@slug/rss":
134 | cond cfg.enableRss
135 | cond @"name" != "i"
136 | let
137 | slug = decodeUrl(@"slug")
138 | list = await getCachedList(@"name", slug)
139 | cursor = getCursor()
140 |
141 | if list.id.len == 0:
142 | resp Http404, showError("List \"" & @"slug" & "\" not found", cfg)
143 |
144 | let url = "/i/lists/" & list.id & "/rss"
145 | if cursor.len > 0:
146 | redirect(url & "?cursor=" & encodeUrl(cursor, false))
147 | else:
148 | redirect(url)
149 |
150 | get "/i/lists/@id/rss":
151 | cond cfg.enableRss
152 | let
153 | id = @"id"
154 | cursor = getCursor()
155 | key = redisKey("lists", id, cursor)
156 |
157 | var rss = await getCachedRss(key)
158 | if rss.cursor.len > 0:
159 | respRss(rss, "List")
160 |
161 | let
162 | list = await getCachedList(id=id)
163 | timeline = await getListTimeline(list.id, cursor)
164 | rss.cursor = timeline.bottom
165 | rss.feed = renderListRss(timeline.content, list, cfg)
166 |
167 | await cacheRss(key, rss)
168 | respRss(rss, "List")
169 |
--------------------------------------------------------------------------------
/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 "favorites": getFavoritesQuery(name)
20 | of "search": initQuery(params(request), name=name)
21 | else: Query(fromUser: @[name])
22 |
23 | template skipIf[T](cond: bool; default; body: Future[T]): Future[T] =
24 | if cond:
25 | let fut = newFuture[T]()
26 | fut.complete(default)
27 | fut
28 | else:
29 | body
30 |
31 | proc fetchProfile*(after: string; query: Query; cfg: Config; skipRail=false;
32 | skipPinned=false): Future[Profile] {.async.} =
33 | let
34 | name = query.fromUser[0]
35 | userId = await getUserId(name)
36 |
37 | if userId.len == 0:
38 | return Profile(user: User(username: name))
39 | elif userId == "suspended":
40 | return Profile(user: User(username: name, suspended: true))
41 |
42 | # temporary fix to prevent errors from people browsing
43 | # timelines during/immediately after deployment
44 | var after = after
45 | if query.kind in {posts, replies} and after.startsWith("scroll"):
46 | after.setLen 0
47 |
48 | let
49 | timeline =
50 | case query.kind
51 | of posts: getTimeline(userId, after)
52 | of replies: getTimeline(userId, after, replies=true)
53 | of media: getMediaTimeline(userId, after)
54 | of favorites: getFavorites(userId, cfg, after)
55 | else: getSearch[Tweet](query, after)
56 |
57 | rail =
58 | skipIf(skipRail or query.kind == media, @[]):
59 | getCachedPhotoRail(name)
60 |
61 | user = await getCachedUser(name)
62 |
63 | var pinned: Option[Tweet]
64 | if not skipPinned and user.pinnedTweet > 0 and
65 | after.len == 0 and query.kind in {posts, replies}:
66 | let tweet = await getCachedTweet(user.pinnedTweet)
67 | if not tweet.isNil:
68 | tweet.pinned = true
69 | pinned = some tweet
70 |
71 | result = Profile(
72 | user: user,
73 | pinned: pinned,
74 | tweets: await timeline,
75 | photoRail: await rail
76 | )
77 |
78 | if result.user.protected or result.user.suspended:
79 | return
80 |
81 | result.tweets.query = query
82 |
83 | proc showTimeline*(request: Request; query: Query; cfg: Config; prefs: Prefs;
84 | rss, after: string): Future[string] {.async.} =
85 | if query.fromUser.len != 1:
86 | let
87 | timeline = await getSearch[Tweet](query, after)
88 | html = renderTweetSearch(timeline, cfg, prefs, getPath())
89 | return renderMain(html, request, cfg, prefs, "Multi", rss=rss)
90 |
91 | var profile = await fetchProfile(after, query, cfg, skipPinned=prefs.hidePins)
92 | template u: untyped = profile.user
93 |
94 | if u.suspended:
95 | return showError(getSuspended(u.username), cfg)
96 |
97 | if profile.user.id.len == 0: return
98 |
99 | let pHtml = renderProfile(profile, cfg, prefs, getPath())
100 | result = renderMain(pHtml, request, cfg, prefs, pageTitle(u), pageDesc(u),
101 | rss=rss, images = @[u.getUserPic("_400x400")],
102 | banner=u.banner)
103 |
104 | template respTimeline*(timeline: typed) =
105 | let t = timeline
106 | if t.len == 0:
107 | resp Http404, showError("User \"" & @"name" & "\" not found", cfg)
108 | resp t
109 |
110 | template respUserId*() =
111 | cond @"user_id".len > 0
112 | let username = await getCachedUsername(@"user_id")
113 | if username.len > 0:
114 | redirect("/" & username)
115 | else:
116 | resp Http404, showError("User not found", cfg)
117 |
118 | proc createTimelineRouter*(cfg: Config) =
119 | router timeline:
120 | get "/i/user/@user_id":
121 | respUserId()
122 |
123 | get "/intent/user":
124 | respUserId()
125 |
126 | get "/@name/?@tab?/?":
127 | cond '.' notin @"name"
128 | cond @"name" notin ["pic", "gif", "video"]
129 | cond @"tab" in ["with_replies", "media", "search", "favorites", ""]
130 | let
131 | prefs = cookiePrefs()
132 | after = getCursor()
133 | names = getNames(@"name")
134 |
135 | var query = request.getQuery(@"tab", @"name")
136 | if names.len != 1:
137 | query.fromUser = names
138 |
139 | # used for the infinite scroll feature
140 | if @"scroll".len > 0:
141 | if query.fromUser.len != 1:
142 | var timeline = await getSearch[Tweet](query, after)
143 | if timeline.content.len == 0: resp Http404
144 | timeline.beginning = true
145 | resp $renderTweetSearch(timeline, cfg, prefs, getPath())
146 | else:
147 | var profile = await fetchProfile(after, query, cfg, skipRail=true)
148 | if profile.tweets.content.len == 0: resp Http404
149 | profile.tweets.beginning = true
150 | resp $renderTimelineTweets(profile.tweets, prefs, getPath())
151 |
152 | let rss =
153 | if @"tab".len == 0:
154 | "/$1/rss" % @"name"
155 | elif @"tab" == "search":
156 | "/$1/search/rss?$2" % [@"name", genQueryUrl(query)]
157 | else:
158 | "/$1/$2/rss" % [@"name", @"tab"]
159 |
160 | respTimeline(await showTimeline(request, query, cfg, prefs, rss, after))
161 |
--------------------------------------------------------------------------------
/src/views/rss.nimf:
--------------------------------------------------------------------------------
1 | #? stdtmpl(subsChar = '$', metaChar = '#')
2 | ## SPDX-License-Identifier: AGPL-3.0-only
3 | #import strutils, xmltree, strformat, options, unicode
4 | #import ../types, ../utils, ../formatters, ../prefs
5 | #
6 | #proc getTitle(tweet: Tweet; retweet: string): string =
7 | #if tweet.pinned: result = "Pinned: "
8 | #elif retweet.len > 0: result = &"RT by @{retweet}: "
9 | #elif tweet.reply.len > 0: result = &"R to @{tweet.reply[0]}: "
10 | #end if
11 | #var text = stripHtml(tweet.text)
12 | ##if unicode.runeLen(text) > 32:
13 | ## text = unicode.runeSubStr(text, 0, 32) & "..."
14 | ##end if
15 | #result &= xmltree.escape(text)
16 | #if result.len > 0: return
17 | #end if
18 | #if tweet.photos.len > 0:
19 | # result &= "Image"
20 | #elif tweet.video.isSome:
21 | # result &= "Video"
22 | #elif tweet.gif.isSome:
23 | # result &= "Gif"
24 | #end if
25 | #end proc
26 | #
27 | #proc getDescription(desc: string; cfg: Config): string =
28 | Twitter feed for: ${desc}. Generated by ${cfg.hostname}
29 | #end proc
30 | #
31 | #proc renderRssTweet(tweet: Tweet; cfg: Config): string =
32 | #let tweet = tweet.retweet.get(tweet)
33 | #let urlPrefix = getUrlPrefix(cfg)
34 | #let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
35 | ${text.replace("\n", "
\n")}
36 | #if tweet.quote.isSome and get(tweet.quote).available:
37 | # let quoteLink = getLink(get(tweet.quote))
38 | ${cfg.hostname}${quoteLink}
39 | #end if
40 | #if tweet.photos.len > 0:
41 | # for photo in tweet.photos:
42 |
43 | # end for
44 | #elif tweet.video.isSome:
45 |
46 | #elif tweet.gif.isSome:
47 | # let thumb = &"{urlPrefix}{getPicUrl(get(tweet.gif).thumb)}"
48 | # let url = &"{urlPrefix}{getPicUrl(get(tweet.gif).url)}"
49 |
51 | #elif tweet.card.isSome:
52 | # let card = tweet.card.get()
53 | # if card.image.len > 0:
54 |
55 | # end if
56 | #end if
57 | #end proc
58 | #
59 | #proc renderRssTweets(tweets: seq[Tweet]; cfg: Config): string =
60 | #let urlPrefix = getUrlPrefix(cfg)
61 | #var links: seq[string]
62 | #for t in tweets:
63 | # let retweet = if t.retweet.isSome: t.user.username else: ""
64 | # let tweet = if retweet.len > 0: t.retweet.get else: t
65 | # let link = getLink(tweet)
66 | # if link in links: continue
67 | # end if
68 | # links.add link
69 | -
70 | ${getTitle(tweet, retweet)}
71 | @${tweet.user.username}
72 |
73 | ${getRfc822Time(tweet)}
74 | ${urlPrefix & link}
75 | ${urlPrefix & link}
76 |
77 | #end for
78 | #end proc
79 | #
80 | #proc renderTimelineRss*(profile: Profile; cfg: Config; multi=false): string =
81 | #let urlPrefix = getUrlPrefix(cfg)
82 | #result = ""
83 | #let handle = (if multi: "" else: "@") & profile.user.username
84 | #var title = profile.user.fullname
85 | #if not multi: title &= " / " & handle
86 | #end if
87 | #title = xmltree.escape(title).sanitizeXml
88 |
89 |
90 |
91 |
92 | ${title}
93 | ${urlPrefix}/${profile.user.username}
94 | ${getDescription(handle, cfg)}
95 | en-us
96 | 40
97 |
98 | ${title}
99 | ${urlPrefix}/${profile.user.username}
100 | ${urlPrefix}${getPicUrl(profile.user.getUserPic(style="_400x400"))}
101 | 128
102 | 128
103 |
104 | #if profile.tweets.content.len > 0:
105 | ${renderRssTweets(profile.tweets.content, cfg)}
106 | #end if
107 |
108 |
109 | #end proc
110 | #
111 | #proc renderListRss*(tweets: seq[Tweet]; list: List; cfg: Config): string =
112 | #let link = &"{getUrlPrefix(cfg)}/i/lists/{list.id}"
113 | #result = ""
114 |
115 |
116 |
117 |
118 | ${xmltree.escape(list.name)} / @${list.username}
119 | ${link}
120 | ${getDescription(&"{list.name} by @{list.username}", cfg)}
121 | en-us
122 | 40
123 | ${renderRssTweets(tweets, cfg)}
124 |
125 |
126 | #end proc
127 | #
128 | #proc renderSearchRss*(tweets: seq[Tweet]; name, param: string; cfg: Config): string =
129 | #let link = &"{getUrlPrefix(cfg)}/search"
130 | #let escName = xmltree.escape(name)
131 | #result = ""
132 |
133 |
134 |
135 |
136 | Search results for "${escName}"
137 | ${link}
138 | ${getDescription(&"Search \"{escName}\"", cfg)}
139 | en-us
140 | 40
141 | ${renderRssTweets(tweets, cfg)}
142 |
143 |
144 | #end proc
145 |
--------------------------------------------------------------------------------
/src/views/general.nim:
--------------------------------------------------------------------------------
1 | # SPDX-License-Identifier: AGPL-3.0-only
2 | import uri, strutils, strformat
3 | import karax/[karaxdsl, vdom]
4 |
5 | import renderutils
6 | import ../utils, ../types, ../prefs, ../formatters
7 |
8 | import jester
9 |
10 | const
11 | doctype = "\n"
12 | lp = readFile("public/lp.svg")
13 |
14 | proc toTheme(theme: string): string =
15 | theme.toLowerAscii.replace(" ", "_")
16 |
17 | proc renderNavbar(cfg: Config; req: Request; rss, canonical: string): VNode =
18 | var path = req.params.getOrDefault("referer")
19 | if path.len == 0:
20 | path = $(parseUri(req.path) ? filterParams(req.params))
21 | if "/status/" in path: path.add "#m"
22 |
23 | buildHtml(nav):
24 | tdiv(class="inner-nav"):
25 | tdiv(class="nav-item"):
26 | a(class="site-name", href="/"): text cfg.title
27 |
28 | a(href="/"): img(class="site-logo", src="/logo.png", alt="Logo")
29 |
30 | tdiv(class="nav-item right"):
31 | icon "search", title="Search", href="/search"
32 | if cfg.enableRss and rss.len > 0:
33 | icon "rss-feed", title="RSS Feed", href=rss
34 | icon "bird", title="Open in Twitter", href=canonical
35 | a(href="https://liberapay.com/zedeus"): verbatim lp
36 | icon "info", title="About", href="/about"
37 | icon "cog", title="Preferences", href=("/settings?referer=" & encodeUrl(path))
38 |
39 | proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
40 | video=""; images: seq[string] = @[]; banner=""; ogTitle="";
41 | rss=""; canonical=""): VNode =
42 | var theme = prefs.theme.toTheme
43 | if "theme" in req.params:
44 | theme = req.params["theme"].toTheme
45 |
46 | let ogType =
47 | if video.len > 0: "video"
48 | elif rss.len > 0: "object"
49 | elif images.len > 0: "photo"
50 | else: "article"
51 |
52 | let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
53 |
54 | buildHtml(head):
55 | link(rel="stylesheet", type="text/css", href="/css/style.css?v=18")
56 | link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
57 |
58 | if theme.len > 0:
59 | link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))
60 |
61 | link(rel="apple-touch-icon", sizes="180x180", href="/apple-touch-icon.png")
62 | link(rel="icon", type="image/png", sizes="32x32", href="/favicon-32x32.png")
63 | link(rel="icon", type="image/png", sizes="16x16", href="/favicon-16x16.png")
64 | link(rel="manifest", href="/site.webmanifest")
65 | link(rel="mask-icon", href="/safari-pinned-tab.svg", color="#ff6c60")
66 | link(rel="search", type="application/opensearchdescription+xml", title=cfg.title,
67 | href=opensearchUrl)
68 |
69 | if canonical.len > 0:
70 | link(rel="canonical", href=canonical)
71 |
72 | if cfg.enableRss and rss.len > 0:
73 | link(rel="alternate", type="application/rss+xml", href=rss, title="RSS feed")
74 |
75 | if prefs.hlsPlayback:
76 | script(src="/js/hls.light.min.js", `defer`="")
77 | script(src="/js/hlsPlayback.js", `defer`="")
78 |
79 | if prefs.infiniteScroll:
80 | script(src="/js/infiniteScroll.js", `defer`="")
81 |
82 | title:
83 | if titleText.len > 0:
84 | text titleText & " | " & cfg.title
85 | else:
86 | text cfg.title
87 |
88 | meta(name="viewport", content="width=device-width, initial-scale=1.0")
89 | meta(name="theme-color", content="#1F1F1F")
90 | meta(property="og:type", content=ogType)
91 | meta(property="og:title", content=(if ogTitle.len > 0: ogTitle else: titleText))
92 | meta(property="og:description", content=stripHtml(desc))
93 | meta(property="og:site_name", content="Nitter")
94 | meta(property="og:locale", content="en_US")
95 |
96 | if banner.len > 0 and not banner.startsWith('#'):
97 | let bannerUrl = getPicUrl(banner)
98 | link(rel="preload", type="image/png", href=bannerUrl, `as`="image")
99 |
100 | for url in images:
101 | let preloadUrl = if "400x400" in url: getPicUrl(url)
102 | else: getSmallPic(url)
103 | link(rel="preload", type="image/png", href=preloadUrl, `as`="image")
104 |
105 | let image = getUrlPrefix(cfg) & getPicUrl(url)
106 | meta(property="og:image", content=image)
107 | meta(property="twitter:image:src", content=image)
108 |
109 | if rss.len > 0:
110 | meta(property="twitter:card", content="summary")
111 | else:
112 | meta(property="twitter:card", content="summary_large_image")
113 |
114 | if video.len > 0:
115 | meta(property="og:video:url", content=video)
116 | meta(property="og:video:secure_url", content=video)
117 | meta(property="og:video:type", content="text/html")
118 |
119 | # this is last so images are also preloaded
120 | # if this is done earlier, Chrome only preloads one image for some reason
121 | link(rel="preload", type="font/woff2", `as`="font",
122 | href="/fonts/fontello.woff2?21002321", crossorigin="anonymous")
123 |
124 | proc renderMain*(body: VNode; req: Request; cfg: Config; prefs=defaultPrefs;
125 | titleText=""; desc=""; ogTitle=""; rss=""; video="";
126 | images: seq[string] = @[]; banner=""): string =
127 |
128 | let canonical = getTwitterLink(req.path, req.params)
129 |
130 | let node = buildHtml(html(lang="en")):
131 | renderHead(prefs, cfg, req, titleText, desc, video, images, banner, ogTitle,
132 | rss, canonical)
133 |
134 | body:
135 | renderNavbar(cfg, req, rss, canonical)
136 |
137 | tdiv(class="container"):
138 | body
139 |
140 | result = doctype & $node
141 |
142 | proc renderError*(error: string): VNode =
143 | buildHtml(tdiv(class="panel-container")):
144 | tdiv(class="error-panel"):
145 | span: verbatim error
146 |
--------------------------------------------------------------------------------