17 Commits

Author SHA1 Message Date
Salastil e98aa346d2 Merge branch 'master' of https://github.com/zedeus/nitter
Docker / build (linux/amd64, ubuntu-24.04) (push) Has been cancelled
Docker / build (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Docker / merge (push) Has been cancelled
Docker / tests (push) Has been cancelled
2026-06-13 11:28:36 -04:00
Zed bd9d492d36 Fix bounds checking
Fixes #1410
2026-06-11 23:39:48 +02:00
Zed 7eed720894 Fix entity expansion crash, simplify URL handling
Fixes #1409
2026-06-10 16:07:13 +02:00
Zed ac5ba9469e Fix incorrectly shown Twitter video cards
Fixes #1407
2026-06-10 14:28:01 +02:00
Zed 63891a04ff Update tests 2026-06-10 06:20:08 +02:00
Zed ac2f93b361 Add skipTid field for cookie session TID handling 2026-06-10 06:20:05 +02:00
Zed 1e33ca045d Fix restIdVars whitespace handling 2026-06-10 06:20:01 +02:00
Zed 5a4c8dd12c Improve retry failure handling
Fixes #1388
2026-06-08 23:59:50 +02:00
Zed fb9107cff6 Fix crash on malformed request paths 2026-06-08 06:14:18 +02:00
Zed 60cb10229f Fix SameSite cookie handling for HTTP 2026-06-08 06:12:58 +02:00
Zed ef1de42593 Update dependencies to version tags 2026-06-08 06:12:52 +02:00
Zed 9b9c86a15c Use Nimble local deps to fix deployment issue
Fixes #1405
2026-06-08 01:08:19 +02:00
Zed 55d067957c Fix arm32 compilation
Fixes #1389
2026-06-07 23:17:43 +02:00
Zed a8bc1bbb2d Fix nimble dependency caching in CI 2026-06-07 20:35:16 +02:00
Zed f629507537 Publish a single multi-arch Docker image
Build amd64 and arm64 natively (no emulation), push each by
digest, then merge them into one multi-arch manifest so
`zedeus/nitter:latest` and `:<sha>` resolve to the right image
on any CPU. Replaces the separate `latest-arm64` tag, which is
no longer needed. Update the README notes accordingly.
2026-06-07 01:24:03 +02:00
Zed 40d17bf042 Use Nim 2.2.6 for Docker builds, unify arm64
nimlang/nim's alpine-regular images cap at 2.2.6 and are now
multi-arch, while Alpine's apk nim is stuck at the segfaulting
2.2.0. Base both arches on 2.2.6-alpine-regular, drop the
separate Dockerfile.arm64, and build ./Dockerfile in the arm64
CI job.

Fixes #1404
2026-06-07 00:43:29 +02:00
Zed 6ab2143df0 Add note about creating nitter.conf for Docker
Fixes #1392
2026-06-07 00:14:43 +02:00
21 changed files with 281 additions and 143 deletions
+71 -21
View File
@@ -7,55 +7,105 @@ on:
branches: branches:
- master - master
concurrency:
group: docker-publish-${{ github.ref }}
cancel-in-progress: true
env:
IMAGE: zedeus/nitter
jobs: jobs:
tests: tests:
uses: ./.github/workflows/run-tests.yml uses: ./.github/workflows/run-tests.yml
secrets: inherit secrets: inherit
build-docker-amd64: build:
needs: [tests] needs: [tests]
runs-on: ubuntu-24.04 strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
platform: linux/amd64
- runner: ubuntu-24.04-arm
platform: linux/arm64
runs-on: ${{ matrix.runner }}
steps: steps:
- name: Prepare platform name
run: echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
env:
platform: ${{ matrix.platform }}
- uses: actions/checkout@v6 - uses: actions/checkout@v6
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with: with:
version: latest version: latest
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push AMD64 Docker image
uses: docker/build-push-action@v3 - name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
platforms: linux/amd64 platforms: ${{ matrix.platform }}
push: true outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }} provenance: false
sbom: false
build-docker-arm64: - name: Export digest
needs: [tests] run: |
runs-on: ubuntu-24.04-arm mkdir -p "${{ runner.temp }}/digests"
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
# Combine the per-arch digests into one multi-arch manifest so that
# `docker pull zedeus/nitter:latest` serves the right image on any CPU.
merge:
needs: [build]
runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v6 - name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx - name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
with: with:
version: latest version: latest
- name: Login to DockerHub - name: Login to DockerHub
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push ARM64 Docker image
uses: docker/build-push-action@v3 - name: Create manifest list and push
with: working-directory: ${{ runner.temp }}/digests
context: . run: |
file: ./Dockerfile.arm64 docker buildx imagetools create \
platforms: linux/arm64 -t ${{ env.IMAGE }}:latest \
push: true -t ${{ env.IMAGE }}:latest-arm64 \
tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64 -t ${{ env.IMAGE }}:${{ github.sha }} \
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ github.sha }}
+15 -10
View File
@@ -32,10 +32,12 @@ jobs:
id: cache-nimble id: cache-nimble
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
path: ~/.nimble path: |
key: ${{ matrix.nim }}-nimble-v3-${{ hashFiles('*.nimble') }} ~/.nimble/pkgcache
~/.nimble/packages_official.json
key: ${{ matrix.nim }}-nimble-v6-${{ hashFiles('*.nimble') }}
restore-keys: | restore-keys: |
${{ matrix.nim }}-nimble-v3- ${{ matrix.nim }}-nimble-v6-
- name: Setup Nim - name: Setup Nim
uses: jiro4989/setup-nim-action@v2 uses: jiro4989/setup-nim-action@v2
@@ -103,10 +105,12 @@ jobs:
- name: Cache Nimble Dependencies - name: Cache Nimble Dependencies
uses: actions/cache@v5 uses: actions/cache@v5
with: with:
path: ~/.nimble path: |
key: 2.2.x-nimble-v3-${{ hashFiles('*.nimble') }} ~/.nimble/pkgcache
~/.nimble/packages_official.json
key: 2.2.x-nimble-v6-${{ hashFiles('*.nimble') }}
restore-keys: | restore-keys: |
2.2.x-nimble-v3- 2.2.x-nimble-v6-
- name: Setup Nim - name: Setup Nim
uses: jiro4989/setup-nim-action@v2 uses: jiro4989/setup-nim-action@v2
@@ -115,6 +119,9 @@ jobs:
use-nightlies: true use-nightlies: true
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Nimble dependencies
run: nimble install -y --depsOnly
- name: Download 2.2.x build artifact - name: Download 2.2.x build artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@@ -130,10 +137,8 @@ jobs:
sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf sed -i 's/enableDebug = false/enableDebug = true/g' nitter.conf
sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf sed -i 's/maxRetries = 1/maxRetries = 10/g' nitter.conf
# Run both Nimble tasks concurrently nim r tools/rendermd.nim
nim r tools/rendermd.nim & nim r tools/gencss.nim
nim r tools/gencss.nim &
wait
echo '${{ secrets.SESSIONS }}' | head -n1 echo '${{ secrets.SESSIONS }}' | head -n1
echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl echo '${{ secrets.SESSIONS }}' > ./sessions.jsonl
+3
View File
@@ -15,3 +15,6 @@ sessions.json*
dump.rdb dump.rdb
*.bak *.bak
/tools/*.json* /tools/*.json*
nimbledeps/
nimble.paths
nimble.develop
+2 -2
View File
@@ -1,4 +1,4 @@
FROM nimlang/nim:2.2.0-alpine-regular as nim FROM nimlang/nim:2.2.6-alpine-regular as nim
LABEL maintainer="setenforce@protonmail.com" LABEL maintainer="setenforce@protonmail.com"
RUN apk --no-cache add libsass-dev pcre RUN apk --no-cache add libsass-dev pcre
@@ -15,7 +15,7 @@ RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
FROM alpine:latest FROM alpine:latest
WORKDIR /src/ WORKDIR /src/
RUN apk --no-cache add pcre ca-certificates RUN apk --no-cache add pcre ca-certificates openssl
COPY --from=nim /src/nitter/nitter ./ COPY --from=nim /src/nitter/nitter ./
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
COPY --from=nim /src/nitter/public ./public COPY --from=nim /src/nitter/public ./public
-25
View File
@@ -1,25 +0,0 @@
FROM alpine:3.20.6 as nim
LABEL maintainer="setenforce@protonmail.com"
RUN apk --no-cache add libsass-dev pcre gcc git libc-dev nim nimble
WORKDIR /src/nitter
COPY nitter.nimble .
RUN nimble install -y --depsOnly
COPY . .
RUN nimble build -d:danger -d:lto -d:strip --mm:refc \
&& nimble scss \
&& nimble md
FROM alpine:3.20.6
WORKDIR /src/
RUN apk --no-cache add pcre ca-certificates openssl
COPY --from=nim /src/nitter/nitter ./
COPY --from=nim /src/nitter/nitter.example.conf ./nitter.conf
COPY --from=nim /src/nitter/public ./public
EXPOSE 8080
RUN adduser -h /src/ -D -s /bin/sh nitter
USER nitter
CMD ./nitter
+20 -8
View File
@@ -104,9 +104,9 @@ along with the scss and md files.
# su nitter # su nitter
$ git clone https://github.com/zedeus/nitter $ git clone https://github.com/zedeus/nitter
$ cd nitter $ cd nitter
$ nimble build -d:danger --mm:refc $ nimble -l build -d:danger --mm:refc
$ nimble scss $ nimble -l scss
$ nimble md $ nimble -l md
$ cp nitter.example.conf nitter.conf $ cp nitter.example.conf nitter.conf
``` ```
@@ -123,12 +123,23 @@ performance reasons.
Page for the Docker image: https://hub.docker.com/r/zedeus/nitter Page for the Docker image: https://hub.docker.com/r/zedeus/nitter
#### NOTE: For ARM64 support, please use the separate ARM64 docker image: [`zedeus/nitter:latest-arm64`](https://hub.docker.com/r/zedeus/nitter/tags). #### NOTE: The published image is multi-arch — `zedeus/nitter:latest` runs natively on both `amd64` and `arm64`.
To run Nitter with Docker, you'll need to install and run Redis separately To run Nitter with Docker, you'll need to install and run Redis separately
before you can run the container. See below for how to also run Redis using before you can run the container. See below for how to also run Redis using
Docker. Docker.
First create your config file. The Docker commands mount it into the container,
so it has to exist on the host beforehand. If you've cloned the repo:
```bash
cp nitter.example.conf nitter.conf
```
If you're using the prebuilt image without a local clone, download
[`nitter.example.conf`](https://raw.githubusercontent.com/zedeus/nitter/master/nitter.example.conf)
and save it as `nitter.conf` instead.
To build and run Nitter in Docker: To build and run Nitter in Docker:
```bash ```bash
@@ -136,8 +147,6 @@ docker build -t nitter:latest .
docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest docker run -v $(pwd)/nitter.conf:/src/nitter.conf -d --network host nitter:latest
``` ```
Note: For ARM64, use this Dockerfile: [`Dockerfile.arm64`](https://github.com/zedeus/nitter/blob/master/Dockerfile.arm64).
A prebuilt Docker image is provided as well: A prebuilt Docker image is provided as well:
```bash ```bash
@@ -151,8 +160,11 @@ Change `redisHost` from `localhost` to `nitter-redis` in `nitter.conf`, then run
docker-compose up -d docker-compose up -d
``` ```
Note the Docker commands expect a `nitter.conf` file in the directory you run Note the Docker commands mount `nitter.conf` (and `sessions.jsonl` for
them. docker-compose) from the directory you run them in. If a mounted file doesn't
exist, Docker silently creates a directory in its place and the container fails
with `not a directory: Are you trying to mount a directory onto a file`. Remove
that directory and create the file as shown above.
### systemd ### systemd
+4
View File
@@ -11,3 +11,7 @@ warning("HoleEnumConv", off)
hint("XDeclaredButNotUsed", off) hint("XDeclaredButNotUsed", off)
hint("XCannotRaiseY", off) hint("XCannotRaiseY", off)
hint("User", off) hint("User", off)
# begin Nimble config (version 2)
when withDir(thisDir(), system.fileExists("nimble.paths")):
include "nimble.paths"
# end Nimble config
+11 -12
View File
@@ -11,19 +11,18 @@ bin = @["nitter"]
# Dependencies # Dependencies
requires "nim >= 2.0.0" requires "nim >= 2.0.0"
requires "jester#baca3f" requires "jester == 0.6.0"
requires "karax#5cf360c" requires "karax == 1.5.0"
requires "sass#7dfdd03" requires "sass == 0.2.0"
requires "nimcrypto#a079df9" requires "nimcrypto == 0.7.3"
requires "markdown#158efe3" requires "markdown == 0.8.8"
requires "packedjson#9e6fbb6" requires "packedjson#9e6fbb6"
requires "supersnappy#6c94198" requires "supersnappy == 2.1.4"
requires "https://github.com/zedeus/redpool#8b7c1db" requires "redpool == 0.2.2"
requires "https://github.com/zedeus/redis#d0a0e6f" requires "zippy == 0.10.19"
requires "zippy#ca5989a" requires "flatty == 0.4.0"
requires "flatty#e668085" requires "jsony == 1.1.6"
requires "jsony#1de1f08" requires "oauth == 0.11"
requires "oauth#b8c163b"
# Tasks # Tasks
+5 -10
View File
@@ -11,11 +11,11 @@ proc genParams(variables: string; fieldToggles = ""): seq[(string, string)] =
if fieldToggles.len > 0: if fieldToggles.len > 0:
result.add ("fieldToggles", fieldToggles) result.add ("fieldToggles", fieldToggles)
proc apiUrl(endpoint, variables: string; fieldToggles = ""): ApiUrl = proc apiUrl(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiUrl =
return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles)) return ApiUrl(endpoint: endpoint, params: genParams(variables, fieldToggles), skipTid: skipTid)
proc apiReq(endpoint, variables: string; fieldToggles = ""): ApiReq = proc apiReq(endpoint, variables: string; fieldToggles = ""; skipTid = false): ApiReq =
let url = apiUrl(endpoint, variables, fieldToggles) let url = apiUrl(endpoint, variables, fieldToggles, skipTid)
return ApiReq(cookie: url, oauth: url) return ApiReq(cookie: url, oauth: url)
proc mediaUrl(id, cursor: string; count=20): ApiReq = proc mediaUrl(id, cursor: string; count=20): ApiReq =
@@ -32,12 +32,7 @@ proc userTweetsUrl(id: string; cursor: string): ApiReq =
# ) # )
proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq = proc userTweetsAndRepliesUrl(id: string; cursor: string): ApiReq =
return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"]) return apiReq(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"], skipTid=true)
#let cookieVars = userTweetsAndRepliesVars % [id, cursor]
# result = ApiReq(
# cookie: apiUrl(graphUserTweetsAndReplies, cookieVars, userTweetsFieldToggles),
# oauth: apiUrl(graphUserTweetsAndRepliesV2, restIdVars % [id, cursor, "20"])
# )
proc tweetDetailUrl(id: string; cursor: string): ApiReq = proc tweetDetailUrl(id: string; cursor: string): ApiReq =
return apiReq(graphTweet, tweetVars % [id, cursor]) return apiReq(graphTweet, tweetVars % [id, cursor])
+11 -4
View File
@@ -1,6 +1,6 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import httpclient, asyncdispatch, options, strutils, uri, times, math, tables import httpclient, asyncdispatch, options, strutils, uri, times, math, tables
import jsony, packedjson, zippy, oauth1 import jsony, packedjson, zippy, oauth/oauth1
import types, auth, consts, parserutils, http_pool, tid import types, auth, consts, parserutils, http_pool, tid
import experimental/types/common import experimental/types/common
@@ -63,7 +63,7 @@ proc getOauthHeader(url, oauthToken, oauthTokenSecret: string): string =
proc getCookieHeader(authToken, ct0: string): string = proc getCookieHeader(authToken, ct0: string): string =
"auth_token=" & authToken & "; ct0=" & ct0 "auth_token=" & authToken & "; ct0=" & ct0
proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} = proc genHeaders*(session: Session, url: Uri, skipTid: bool): Future[HttpHeaders] {.async.} =
result = newHttpHeaders({ result = newHttpHeaders({
"accept": "*/*", "accept": "*/*",
"accept-encoding": "gzip", "accept-encoding": "gzip",
@@ -91,7 +91,7 @@ proc genHeaders*(session: Session, url: Uri): Future[HttpHeaders] {.async.} =
result["sec-fetch-dest"] = "empty" result["sec-fetch-dest"] = "empty"
result["sec-fetch-mode"] = "cors" result["sec-fetch-mode"] = "cors"
result["sec-fetch-site"] = "same-origin" result["sec-fetch-site"] = "same-origin"
if disableTid or "/1.1/" in url.path: if disableTid or skipTid or "/1.1/" in url.path:
result["authorization"] = bearerToken2 result["authorization"] = bearerToken2
else: else:
result["authorization"] = bearerToken result["authorization"] = bearerToken
@@ -115,7 +115,10 @@ template fetchImpl(result, fetchBody) {.dirty.} =
try: try:
var resp: AsyncResponse var resp: AsyncResponse
let headers = await genHeaders(session, url) let skipTid = case session.kind
of oauth: req.oauth.skipTid
of cookie: req.cookie.skipTid
let headers = await genHeaders(session, url, skipTid)
pool.use(headers): pool.use(headers):
template getContent = template getContent =
@@ -182,10 +185,12 @@ template fetchImpl(result, fetchBody) {.dirty.} =
template retry(bod) {.dirty.} = template retry(bod) {.dirty.} =
var session: Session var session: Session
var retrySuccess = false
for i in 0 ..< maxRetries: for i in 0 ..< maxRetries:
try: try:
session = nil session = nil
bod bod
retrySuccess = true
break break
except RateLimitError: except RateLimitError:
let api = if session.isNil: req.cookie.endpoint let api = if session.isNil: req.cookie.endpoint
@@ -199,6 +204,8 @@ template retry(bod) {.dirty.} =
session = nil session = nil
if retryDelayMs > 0: if retryDelayMs > 0:
await sleepAsync(retryDelayMs) await sleepAsync(retryDelayMs)
if not retrySuccess:
raise rateLimitError()
proc fetch*(req: ApiReq): Future[JsonNode] {.async.} = proc fetch*(req: ApiReq): Future[JsonNode] {.async.} =
retry: retry:
+1 -1
View File
@@ -104,7 +104,7 @@ const
restIdVars* = """{ restIdVars* = """{
"rest_id": "$1", $2 "rest_id": "$1", $2
"count": $3 "count": $3
}""" }""".replace(" ", "").replace("\n", "")
userMediaVars* = """{ userMediaVars* = """{
"userId": "$1", $2 "userId": "$1", $2
+13 -5
View File
@@ -140,25 +140,30 @@ proc pageDesc*(user: User): string =
"The latest tweets from " & user.fullname "The latest tweets from " & user.fullname
proc getJoinDate*(user: User): string = proc getJoinDate*(user: User): string =
if user.joinDate.year == 0: return ""
user.joinDate.format("'Joined' MMMM YYYY") user.joinDate.format("'Joined' MMMM YYYY")
proc getJoinDateFull*(user: User): string = proc getJoinDateFull*(user: User): string =
if user.joinDate.year == 0: return ""
user.joinDate.format("h:mm tt - d MMM YYYY") user.joinDate.format("h:mm tt - d MMM YYYY")
proc getTime*(tweet: Tweet): string = proc getTime*(tweet: Tweet): string =
if tweet.time.year == 0: return ""
tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'") tweet.time.format("MMM d', 'YYYY' · 'h:mm tt' UTC'")
proc getRfc822Time*(tweet: Tweet): string = proc getRfc822Time*(tweet: Tweet): string =
if tweet.time.year == 0: return ""
tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'") tweet.time.format("ddd', 'dd MMM yyyy HH:mm:ss 'GMT'")
proc getShortTime*(tweet: Tweet): string = proc getShortTime*(time: DateTime): string =
if time.year == 0: return ""
let now = now() let now = now()
let since = now - tweet.time let since = now - time
if now.year != tweet.time.year: if now.year != time.year:
result = tweet.time.format("d MMM yyyy") result = time.format("d MMM yyyy")
elif since.inDays >= 1: elif since.inDays >= 1:
result = tweet.time.format("MMM d") result = time.format("MMM d")
elif since.inHours >= 1: elif since.inHours >= 1:
result = $since.inHours & "h" result = $since.inHours & "h"
elif since.inMinutes >= 1: elif since.inMinutes >= 1:
@@ -168,6 +173,9 @@ proc getShortTime*(tweet: Tweet): string =
else: else:
result = "now" result = "now"
proc getShortTime*(tweet: Tweet): string =
getShortTime(tweet.time)
proc getDuration*(ms: int): string = proc getDuration*(ms: int): string =
let let
sec = int(round(ms / 1000)) sec = int(round(ms / 1000))
+7 -2
View File
@@ -2,7 +2,7 @@
import asyncdispatch, strformat, logging import asyncdispatch, strformat, logging
from net import Port from net import Port
from htmlgen import a from htmlgen import a
from os import getEnv from os import getEnv, normalizedPath
import jester import jester
@@ -63,12 +63,17 @@ createDebugRouter(cfg)
settings: settings:
port = Port(cfg.port) port = Port(cfg.port)
staticDir = cfg.staticDir staticDir = normalizedPath(cfg.staticDir)
bindAddr = cfg.address bindAddr = cfg.address
reusePort = true reusePort = true
maxBody = 64 * 1024
routes: routes:
before: before:
# Reject malformed paths
if request.path.len == 0 or request.path[0] != '/':
halt Http400
# skip all file URLs # skip all file URLs
cond "." notin request.path cond "." notin request.path
applyUrlPrefs() applyUrlPrefs()
+21 -26
View File
@@ -1,5 +1,5 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
import strutils, options, times, math, tables import strutils, options, times, math, tables, uri
import packedjson, packedjson/deserialiser import packedjson, packedjson/deserialiser
import types, parserutils, utils import types, parserutils, utils
import experimental/parser/unifiedcard import experimental/parser/unifiedcard
@@ -229,6 +229,10 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
result.attribution = some(parseUser(user)) result.attribution = some(parseUser(user))
else: else:
result.attribution = some(parseGraphUser(user)) result.attribution = some(parseGraphUser(user))
# Set attribution link from expanded_url (strip /video/N suffix)
let expanded = m{"expanded_url"}.getStr
if expanded.len > 0:
result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
of "animated_gif": of "animated_gif":
result.media.addMedia(Gif( result.media.addMedia(Gif(
url: m{"video_info", "variants"}[0]{"url"}.getImageStr, url: m{"video_info", "variants"}[0]{"url"}.getImageStr,
@@ -237,11 +241,6 @@ proc parseLegacyMediaEntities(js: JsonNode; result: var Tweet) =
)) ))
else: discard else: discard
with url, m{"url"}:
if result.text.endsWith(url.getStr):
result.text.removeSuffix(url.getStr)
result.text = result.text.strip()
proc parseMediaEntities(js: JsonNode; result: var Tweet) = proc parseMediaEntities(js: JsonNode; result: var Tweet) =
with mediaEntities, js{"media_entities"}: with mediaEntities, js{"media_entities"}:
var parsedMedia: MediaEntities var parsedMedia: MediaEntities
@@ -266,11 +265,9 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) =
# Parse source user for video attribution # Parse source user for video attribution
with sourceUser, mediaEntity{"source_user_results", "result"}: with sourceUser, mediaEntity{"source_user_results", "result"}:
if result.attribution.isNone: if result.attribution.isNone:
let let expanded = mediaEntity{"expanded_url"}.getStr
expanded = mediaEntity{"expanded_url"}.getStr if expanded.len > 0:
pathStart = expanded.find('/', expanded.find("://") + 3) result.attributionLink = expanded.parseUri.path.replace("/video/1", "")
if pathStart >= 0:
result.attributionLink = expanded[pathStart .. ^1].replace("/video/1", "")
result.attribution = some(User( result.attribution = some(User(
id: sourceUser{"rest_id"}.getStr, id: sourceUser{"rest_id"}.getStr,
fullname: sourceUser{"core", "name"}.getStr, fullname: sourceUser{"core", "name"}.getStr,
@@ -284,23 +281,9 @@ proc parseMediaEntities(js: JsonNode; result: var Tweet) =
)) ))
else: discard else: discard
if "expanded_url" in mediaEntity:
let expandedUrl = js.getExpandedUrl
if result.text.endsWith(expandedUrl):
result.text.removeSuffix(expandedUrl)
result.text = result.text.strip()
if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len: if mediaEntities.len > 0 and parsedMedia.len == mediaEntities.len:
result.media = parsedMedia result.media = parsedMedia
# Remove media URLs from text
with mediaList, js{"legacy", "entities", "media"}:
for url in mediaList:
let expandedUrl = url.getExpandedUrl
if result.text.endsWith(expandedUrl):
result.text.removeSuffix(expandedUrl)
result.text = result.text.strip()
proc parsePromoVideo(js: JsonNode): Video = proc parsePromoVideo(js: JsonNode): Video =
result = Video( result = Video(
thumb: js{"player_image_large"}.getImageVal, thumb: js{"player_image_large"}.getImageVal,
@@ -521,7 +504,7 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
"binding_values": %bindingObj "binding_values": %bindingObj
} }
var replyId = 0 var replyId: int64 = 0
with restId, js{"reply_to_results", "rest_id"}: with restId, js{"reply_to_results", "rest_id"}:
replyId = restId.getId replyId = restId.getId
@@ -555,6 +538,10 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
elif name.len > 0 and jsCard{"binding_values"}.notNull: elif name.len > 0 and jsCard{"binding_values"}.notNull:
result.card = some parseCard(jsCard, js{"url_entities"}) result.card = some parseCard(jsCard, js{"url_entities"})
parseMediaEntities(js, result)
if result.attribution.isNone:
parseLegacyMediaEntities(js{"legacy"}, result)
result.expandTweetEntitiesV2(js) result.expandTweetEntitiesV2(js)
# Strip video source URL from text (for videos from other tweets) # Strip video source URL from text (for videos from other tweets)
@@ -585,6 +572,14 @@ proc parseGraphTweet*(js: JsonNode): Tweet =
parseMediaEntities(js, result) parseMediaEntities(js, result)
# Hide card if it's redundant with attribution (same video shown via embed)
if result.attribution.isSome and result.card.isSome:
let cardUri = get(result.card).url.parseUri
if cardUri.isTwitterUrl:
let cardPath = cardUri.path.replace("/video/1", "")
if cardPath.len > 0 and cardPath == result.attributionLink:
get(result.card).kind = hidden
# Handle retweets - check both legacy and top-level paths # Handle retweets - check both legacy and top-level paths
with reposts, js{"legacy", "repostedStatusResults"}: with reposts, js{"legacy", "repostedStatusResults"}:
with rt, reposts{"result"}: with rt, reposts{"result"}:
+25 -8
View File
@@ -202,15 +202,28 @@ proc extractHashtags(result: var seq[ReplaceSlice]; js: JsonNode) =
proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice]; proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
textSlice: Slice[int]): string = textSlice: Slice[int]): string =
let
runeLen = runes.len
safeStart = max(0, textSlice.a)
safeEnd = min(runeLen, textSlice.b)
var validRepls: seq[ReplaceSlice]
for rep in repls:
if rep.slice.a >= 0 and rep.slice.b >= 0 and rep.slice.b < runeLen and rep.slice.a <= rep.slice.b:
validRepls.add rep
template extractLowerBound(i: int; idx): int = template extractLowerBound(i: int; idx): int =
if i > 0: repls[idx].slice.b.succ else: textSlice.a if i > 0: min(validRepls[idx].slice.b.succ, runeLen) else: safeStart
result = newStringOfCap(runes.len) result = newStringOfCap(runes.len)
for i, rep in repls: for i, rep in validRepls:
result.add $runes[extractLowerBound(i, i - 1) ..< rep.slice.a] let lower = extractLowerBound(i, i - 1)
if lower < rep.slice.a:
result.add $runes[lower ..< rep.slice.a]
case rep.kind case rep.kind
of rkHashtag: of rkHashtag:
if rep.slice.a.succ <= rep.slice.b:
let let
name = $runes[rep.slice.a.succ .. rep.slice.b] name = $runes[rep.slice.a.succ .. rep.slice.b]
symbol = $runes[rep.slice.a] symbol = $runes[rep.slice.a]
@@ -222,8 +235,8 @@ proc replacedWith(runes: seq[Rune]; repls: openArray[ReplaceSlice];
of rkRemove: of rkRemove:
discard discard
let rest = extractLowerBound(repls.len, ^1) ..< textSlice.b let rest = extractLowerBound(validRepls.len, ^1) ..< safeEnd
if rest.a <= rest.b: if rest.a >= 0 and rest.a <= rest.b and rest.b <= runeLen:
result.add $runes[rest] result.add $runes[rest]
proc deduplicate(s: var seq[ReplaceSlice]) = proc deduplicate(s: var seq[ReplaceSlice]) =
@@ -326,7 +339,8 @@ proc expandTweetEntities*(tweet: Tweet; js: JsonNode) =
replyTo = reply.getStr replyTo = reply.getStr
tweet.reply.add replyTo tweet.reply.add replyTo
tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo, hasQuote or hasJobCard) tweet.expandTextEntities(entities, tweet.text, textSlice, replyTo,
hasQuote or hasJobCard)
proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int]; proc expandTextEntitiesV2(tweet: Tweet; js: JsonNode; text: string; textSlice: Slice[int];
hasRedundantLink=false) = hasRedundantLink=false) =
@@ -377,16 +391,19 @@ proc expandTweetEntitiesV2*(tweet: Tweet; js: JsonNode) =
textSlice = textRange{0}.getInt .. textRange{1}.getInt textSlice = textRange{0}.getInt .. textRange{1}.getInt
hasQuote = "quoted_tweet_results" in js hasQuote = "quoted_tweet_results" in js
hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails hasJobCard = tweet.card.isSome and get(tweet.card).kind == jobDetails
hasAttribution = tweet.attribution.isSome
tweet.expandTextEntitiesV2(js, tweet.text, textSlice, hasQuote or hasJobCard) tweet.expandTextEntitiesV2(js, tweet.text, textSlice,
hasQuote or hasJobCard or hasAttribution)
proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) = proc expandNoteTweetEntities*(tweet: Tweet; js: JsonNode) =
let let
entities = ? js{"entity_set"} entities = ? js{"entity_set"}
text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose)) text = js{"text"}.getStr.multiReplace(("<", unicodeOpen), (">", unicodeClose))
textSlice = 0..text.runeLen textSlice = 0..text.runeLen
hasAttribution = tweet.attribution.isSome
tweet.expandTextEntities(entities, text, textSlice) tweet.expandTextEntities(entities, text, textSlice, hasRedundantLink=hasAttribution)
tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose)) tweet.text = tweet.text.multiReplace((unicodeOpen, xmlOpen), (unicodeClose, xmlClose))
+2 -1
View File
@@ -144,8 +144,9 @@ proc getCachedUsername*(userId: string): Future[string] {.async.} =
else: else:
let user = await getGraphUserById(userId) let user = await getGraphUserById(userId)
result = user.username result = user.username
if result.len > 0:
await setEx(key, baseCacheTime, result) await setEx(key, baseCacheTime, result)
if result.len > 0 and user.id.len > 0: if user.id.len > 0:
await all(cacheUserId(result, user.id), cache(user)) await all(cacheUserId(result, user.id), cache(user))
# proc getCachedTweet*(id: int64): Future[Tweet] {.async.} = # proc getCachedTweet*(id: int64): Future[Tweet] {.async.} =
+2 -1
View File
@@ -8,8 +8,9 @@ export utils, prefs, types, uri
template savePref*(pref, value: string; req: Request; expire=false) = template savePref*(pref, value: string; req: Request; expire=false) =
if not expire or pref in cookies(req): if not expire or pref in cookies(req):
let sameSite = if cfg.useHttps: None else: Lax
setCookie(pref, value, daysForward(when expire: -10 else: 360), setCookie(pref, value, daysForward(when expire: -10 else: 360),
httpOnly=true, secure=cfg.useHttps, sameSite=None, path="/") httpOnly=true, secure=cfg.useHttps, sameSite=sameSite, path="/")
template requestPrefs*(): untyped {.dirty.} = template requestPrefs*(): untyped {.dirty.} =
getPrefs(cookies(request), params(request)) getPrefs(cookies(request), params(request))
+1
View File
@@ -16,6 +16,7 @@ type
ApiUrl* = object ApiUrl* = object
endpoint*: string endpoint*: string
params*: seq[(string, string)] params*: seq[(string, string)]
skipTid*: bool
ApiReq* = object ApiReq* = object
oauth*: ApiUrl oauth*: ApiUrl
View File
+1 -1
View File
@@ -14,7 +14,7 @@ card = [
'gist.github.com', True], 'gist.github.com', True],
['NASA/status/2061872347477418301', ['NASA/status/2061872347477418301',
'Nancy Grace Roman Space Telescope Mission - NASA Science', 'Nancy Grace Roman Space Telescope - NASA Science',
'The Nancy Grace Roman Space Telescope will settle essential questions in the areas of dark energy, exoplanets, and astrophysics.', 'The Nancy Grace Roman Space Telescope will settle essential questions in the areas of dark energy, exoplanets, and astrophysics.',
'science.nasa.gov', True] 'science.nasa.gov', True]
] ]
+60
View File
@@ -0,0 +1,60 @@
import subprocess
from parameterized import parameterized
BASE_URL = 'http://localhost:8080'
def curl_status(url):
"""Get HTTP status code using curl to avoid URL normalization by Python libs."""
result = subprocess.run(
['curl', '-s', '-o', '/dev/null', '-w', '%{http_code}', url],
capture_output=True, text=True, timeout=10
)
return int(result.stdout)
class TestMalformedPaths:
"""Test that malformed paths don't crash the server.
URLs like //foo are parsed as having 'foo' as the authority (host),
resulting in an empty path. Empty paths previously crashed jester's
static file handler. Now they return 400.
URLs like //foo/bar are parsed as authority='foo', path='/bar',
so they route normally (not empty path).
"""
@parameterized.expand([
# These parse to empty paths -> 400
('//lefty_rae', 400),
('//test', 400),
('//anyuser', 400),
])
def test_empty_path_returns_400(self, path, expected_status):
"""URLs that parse to empty paths should return 400, not crash."""
status = curl_status(f'{BASE_URL}{path}')
assert status == expected_status, \
f'Expected {expected_status} for {path}, got {status}'
@parameterized.expand([
('/jack', 200),
('/about', 200),
('/', 200),
])
def test_normal_paths_work(self, path, expected_status):
"""Normal paths should still work."""
status = curl_status(f'{BASE_URL}{path}')
assert status == expected_status, \
f'Expected {expected_status} for {path}, got {status}'
def test_server_survives_malformed_requests(self):
"""Server should handle malformed requests without crashing."""
# These all parse to empty paths
malformed_paths = ['//a', '//b', '//c', '//user', '//test']
for path in malformed_paths:
status = curl_status(f'{BASE_URL}{path}')
assert status == 400, f'Expected 400 for {path}, got {status}'
# Verify server is still responding after malformed requests
status = curl_status(f'{BASE_URL}/')
assert status == 200, 'Server should still be alive'