├── 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 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/experimental/types/graphlistmembers.nim: -------------------------------------------------------------------------------- 1 | import graphuser 2 | 3 | type 4 | GraphListMembers* = object 5 | data*: tuple[list: List] 6 | 7 | List = object 8 | membersTimeline*: tuple[timeline: Timeline] 9 | 10 | Timeline = object 11 | instructions*: seq[Instruction] 12 | 13 | Instruction = object 14 | kind*: string 15 | entries*: seq[tuple[content: Content]] 16 | 17 | ContentEntryType* = enum 18 | TimelineTimelineItem 19 | TimelineTimelineCursor 20 | 21 | Content = object 22 | case entryType*: ContentEntryType 23 | of TimelineTimelineItem: 24 | itemContent*: tuple[userResults: UserData] 25 | of TimelineTimelineCursor: 26 | value*: string 27 | cursorType*: string 28 | 29 | proc renameHook*(v: var Instruction; fieldName: var string) = 30 | if fieldName == "type": 31 | fieldName = "kind" 32 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------