Compare commits

...

57 commits

Author SHA1 Message Date
Stanislaw Halik
9fa03b99a2 remove likes link below tweet 2024-06-30 15:56:21 +02:00
user
8a3261c928 Merge branch 'master' of https://github.com/taskylizard/shitter 2024-06-30 15:37:42 +02:00
taskylizard
049347a857
fix: hls playback 2024-06-21 21:41:28 +00:00
Andre Gil
b47881967d Include quoted tweet body in RSS 2024-06-21 06:20:45 +02:00
taskylizard
5895d2e276
feat: image zooming 2024-06-10 21:01:42 +00:00
Stanislaw Halik
4cf25d20c7 Merge remote-tracking branch 'privacydevel/master' 2024-05-31 15:35:24 +02:00
taskylizard
a57b66a1f3
feat(profile): add data-profile-id on profile cards 2024-05-21 17:42:38 +00:00
taskylizard
769cb16a6b
feat: Timeline feed for followed accounts 2024-05-20 15:03:29 +00:00
taskylizard
c53b8d4d8a
feat(prefs): imgur & reddit link replacers 2024-05-20 13:01:40 +00:00
taskylizard
a451755d2b
format 2024-05-19 07:05:01 +00:00
PrivacyDevel
7350155d2d
Merge pull request #50 from cmj/master
Fix Like tab, add token generation script + cleanup
2024-04-07 05:06:30 +00:00
cmj
bf7e194ea3 update instructions 2024-04-06 13:20:11 -07:00
cmj
28887c18db twitter oauth token script 2024-04-06 13:13:19 -07:00
cmj
2ac79dcb7e undel debut line 2024-04-06 10:28:10 -07:00
cmj
b41679f9f3 update screenshot with likes tab 2024-04-06 10:18:01 -07:00
cmj
25bbbea809 remove debug lines 2024-04-06 10:12:18 -07:00
cmj
d0259c4814 no extra tokens needed 2024-04-06 10:08:29 -07:00
cmj
188445e96b sticky Likes tab 2024-04-06 10:05:14 -07:00
cmj
f14409f5f5 update parser 2024-04-06 10:03:35 -07:00
cmj
26ee35b7fc likes graphql endpoint 2024-04-06 09:59:59 -07:00
Stanislaw Halik
78b63f3f16 Merge remote-tracking branch 'origin/guest_accounts' 2024-02-26 00:15:33 +01:00
PrivacyDev
cb0d360516 Disabling token expiration 2024-02-21 22:05:50 -05:00
PrivacyDev
7d846ed759 Merge remote-tracking branch 'upstream/guest_accounts' 2023-12-07 11:53:06 -05:00
PrivacyDev
e4e7fe5f00 Merge remote-tracking branch 'upstream/guest_accounts' 2023-09-15 17:03:51 -04:00
PrivacyDev
b313bb0e72 Merge remote-tracking branch 'upstream/guest_accounts' 2023-09-01 17:44:44 -04:00
PrivacyDev
f290b7b5e7 Merge remote-tracking branch 'upstream/guest_accounts' 2023-08-20 17:13:23 -04:00
PrivacyDev
813a71e4d3 fixed build errors 2023-07-22 11:48:49 -04:00
PrivacyDev
b2beabf6cd Merge remote-tracking branch 'upstream/master' 2023-07-22 09:30:11 -04:00
PrivacyDev
41787a9451 fixed build errors 2023-07-21 18:56:13 -04:00
PrivacyDevel
b2cc63cd99
Merge branch 'zedeus:master' into master 2023-07-21 22:38:31 +00:00
PrivacyDev
b67dc67b22 removed .github/workflows/build-docker.yml 2023-07-13 23:24:09 -04:00
PrivacyDev
f15a72e89d fixed empty tweet author headers 2023-07-13 23:22:02 -04:00
PrivacyDev
8bcab11109 Merge remote-tracking branch 'upstream/master' 2023-07-13 21:28:33 -04:00
PrivacyDev
0f3203e903 fixed bug that caused some retweets to be rendered as truncated tweets starting with the text "RT @" 2023-06-17 23:06:20 -04:00
Zed
efdedd3619 Add proper tombstone for subscriber tweets 2023-06-17 21:28:57 -04:00
PrivacyDev
6bd21d6f0a turned user tweets and likes stats into hyperlinks 2023-06-14 17:34:15 -04:00
PrivacyDev
ced0599e89 updated docker-compose to use fork image from ghcr.io 2023-06-14 15:35:28 -04:00
PrivacyDevel
719a08169d
Merge pull request #9 from yingziwu/master
Add the github action to build and publish docker image
2023-06-14 19:07:19 +00:00
bgme
71fab89046 update build-publish-docker.yml 2023-06-07 17:50:13 +08:00
bgme
4bdad64060 add build-publish-docker.yml 2023-06-07 17:34:22 +08:00
PrivacyDev
25b788428b fixed compiler error by using a variable for a case statement 2023-06-06 07:05:02 -04:00
PrivacyDev
2ce3ee6d84 added feature to view who a user follows or is followed by (won't compile because of a compiler bug) 2023-06-05 22:38:17 -04:00
PrivacyDev
1150a59e38 added missing Api.favorites to getPoolJson 2023-06-05 19:48:25 -04:00
PrivacyDev
7a89401f04 turned quote stat in tweet-stat into a clickable link to the quotes 2023-06-05 19:47:10 -04:00
PrivacyDev
ba9a4714e2 added favoriters and retweeters links to tweet-stats 2023-06-04 23:31:07 -04:00
PrivacyDev
e4eea3d2df added favoriters and retweeters endpoints 2023-06-02 23:47:05 -04:00
PrivacyDev
208c39db87 fixed bug that caused everybody to be displayed as verified 2023-05-30 12:02:22 -04:00
PrivacyDevel
7753e44d36
Merge pull request #2 from PrivacyDevel/graphql
Graphql
2023-05-26 21:34:07 +00:00
PrivacyDev
1634ffdf43 fixed bug that caused threads on user profiles to be hidden 2023-05-26 17:23:40 -04:00
PrivacyDev
12f2e16c81 Merge branch 'master' of https://github.com/zedeus/nitter into graphql 2023-04-21 17:43:18 -04:00
Zed
892caaf796 Prevent search endpoint from discarding tokens 2023-04-21 14:17:50 -04:00
PrivacyDev
e6e30baa43 raise a RateLimitError when Twitter returns HTTP status 429 2023-04-18 22:19:38 -04:00
PrivacyDev
11279e2b4f added authentication headers to user search for nsfw users 2023-04-16 02:05:45 -04:00
PrivacyDev
6875569bf2 stopped using Twitter session info for userID requests 2023-04-09 17:32:57 -04:00
PrivacyDev
d5689f2253 added login-based workaround to view NSFW content 2023-04-08 10:33:49 -04:00
PrivacyDev
a6dd229444 fixed token issue that broke all pages besides the favorites / likes timeline 2023-04-05 01:14:30 -04:00
PrivacyDev
7d2a558e89 added favorites endpoint and added likes tab to profile pages 2023-04-04 23:55:01 -04:00
30 changed files with 740 additions and 465 deletions

View file

@ -1,62 +0,0 @@
name: Docker
on:
push:
paths-ignore:
- "README.md"
branches:
- master
jobs:
tests:
uses: ./.github/workflows/run-tests.yml
build-docker-amd64:
needs: [tests]
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
version: latest
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push AMD64 Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile
platforms: linux/amd64
push: true
tags: zedeus/nitter:latest,zedeus/nitter:${{ github.sha }}
build-docker-arm64:
needs: [tests]
runs-on: buildjet-2vcpu-ubuntu-2204-arm
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
with:
version: latest
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and push ARM64 Docker image
uses: docker/build-push-action@v3
with:
context: .
file: ./Dockerfile.arm64
platforms: linux/arm64
push: true
tags: zedeus/nitter:latest-arm64,zedeus/nitter:${{ github.sha }}-arm64

View file

@ -0,0 +1,60 @@
name: Build and Publish Docker
on:
push:
branches: ["master"]
paths-ignore: ["README.md"]
pull_request:
branches: ["master"]
paths-ignore: ["README.md"]
env:
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v2
with:
platforms: arm64
- name: Setup Docker buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Log in to GHCR
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/${{ env.IMAGE_NAME }}
- name: Build and push all platforms Docker image
id: build-and-push
uses: docker/build-push-action@v4
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max

View file

@ -33,6 +33,10 @@ XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscW
- Archiving tweets/profiles - Archiving tweets/profiles
- Developer API - Developer API
## New Features
- Likes tab
## Resources ## Resources
The wiki contains The wiki contains
@ -99,6 +103,12 @@ $ nimble md
$ cp nitter.example.conf nitter.conf $ cp nitter.example.conf nitter.conf
``` ```
Edit `twitter_oauth.sh` with your Twitter account name and password.
```
$ ./twitter_oauth.sh | tee -a guest_accounts.jsonl
```
Set your hostname, port, HMAC key, https (must be correct for cookies), and Set your hostname, port, HMAC key, https (must be correct for cookies), and
Redis info in `nitter.conf`. To run Redis, either run Redis info in `nitter.conf`. To run Redis, either run
`redis-server --daemonize yes`, or `systemctl enable --now redis` (or `redis-server --daemonize yes`, or `systemctl enable --now redis` (or

View file

@ -1,34 +1,14 @@
version: "3" version: "3"
networks:
nitter:
services: services:
# proxy:
# hostname: nitter-proxy
# container_name: nitter-proxy
# build:
# context: ./proxy
# dockerfile: Dockerfile
# environment:
# HOST: "0.0.0.0"
# PORT: "8080"
# NITTER_BASE_URL: "http://nitter:8080"
# CONCURRENCY: "1"
# ports:
# - "8002:8080"
# networks:
# - nitter
nitter: nitter:
build: . image: ghcr.io/privacydevel/nitter:master
container_name: nitter container_name: nitter
hostname: nitter
ports: ports:
- "8002:8080" # Replace with "8080:8080" if you don't use a reverse proxy - "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy
volumes: volumes:
- ./nitter.conf:/src/nitter.conf:Z,ro - ./nitter.conf:/src/nitter.conf:Z,ro
- ./guest_accounts.json:/src/guest_accounts.json:Z,ro
depends_on: depends_on:
- nitter-redis - nitter-redis
restart: unless-stopped restart: unless-stopped
@ -43,8 +23,6 @@ services:
- no-new-privileges:true - no-new-privileges:true
cap_drop: cap_drop:
- ALL - ALL
networks:
- nitter
nitter-redis: nitter-redis:
image: redis:6-alpine image: redis:6-alpine
@ -64,8 +42,6 @@ services:
- no-new-privileges:true - no-new-privileges:true
cap_drop: cap_drop:
- ALL - ALL
networks:
- nitter
volumes: volumes:
nitter-redis: nitter-redis:

2
proxy/.prettierignore Normal file
View file

@ -0,0 +1,2 @@
**/*.md
pnpm-lock.yaml

6
proxy/.prettierrc.yaml Normal file
View file

@ -0,0 +1,6 @@
proseWrap: always
semi: false
singleQuote: true
printWidth: 80
trailingComma: none
htmlWhitespaceSensitivity: ignore

View file

@ -4,6 +4,7 @@
"scripts": { "scripts": {
"clean": "rm -rf build", "clean": "rm -rf build",
"prebuild": "npm run clean", "prebuild": "npm run clean",
"format": "prettier -w --cache --check .",
"build": "tsc --build" "build": "tsc --build"
}, },
"author": "", "author": "",
@ -22,6 +23,7 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.12.12", "@types/node": "^20.12.12",
"dotenv": "^16.4.4" "dotenv": "^16.4.4",
"prettier": "^3.2.5"
} }
} }

View file

@ -45,6 +45,9 @@ importers:
dotenv: dotenv:
specifier: ^16.4.4 specifier: ^16.4.4
version: 16.4.4 version: 16.4.4
prettier:
specifier: ^3.2.5
version: 3.2.5
packages: packages:
@ -292,6 +295,11 @@ packages:
resolution: {integrity: sha512-Mz/gKiRyuXu4HnpHgi1YWdHQCoWMufapzooisvFn78zl4dZciAxS+YeRkUxXl1ee/SzU80YCz1zpECCh4oC6Aw==} resolution: {integrity: sha512-Mz/gKiRyuXu4HnpHgi1YWdHQCoWMufapzooisvFn78zl4dZciAxS+YeRkUxXl1ee/SzU80YCz1zpECCh4oC6Aw==}
hasBin: true hasBin: true
prettier@3.2.5:
resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==}
engines: {node: '>=14'}
hasBin: true
process-warning@2.3.2: process-warning@2.3.2:
resolution: {integrity: sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==} resolution: {integrity: sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==}
@ -668,6 +676,8 @@ snapshots:
sonic-boom: 3.8.0 sonic-boom: 3.8.0
thread-stream: 2.4.1 thread-stream: 2.4.1
prettier@3.2.5: {}
process-warning@2.3.2: {} process-warning@2.3.2: {}
process-warning@3.0.0: {} process-warning@3.0.0: {}

View file

@ -1,89 +1,116 @@
import fastify, { import fastify, {
FastifyInstance, FastifyInstance,
FastifyListenOptions, FastifyListenOptions,
FastifyReply, FastifyReply,
FastifyRequest, FastifyRequest
} from "fastify" } from 'fastify'
import { PinoLoggerOptions } from "fastify/types/logger" import { PinoLoggerOptions } from 'fastify/types/logger'
import { Proxy } from "./proxy" import { Proxy } from './proxy'
import { Logger } from "pino" import { Logger } from 'pino'
import 'dotenv/config' import 'dotenv/config'
const host = process.env.HOST const host = process.env.HOST
const port = parseInt(process.env.PORT ?? "8080", 10) const port = parseInt(process.env.PORT ?? '8080', 10)
const baseUrl = process.env.NITTER_BASE_URL const baseUrl = process.env.NITTER_BASE_URL
const concurrency = parseInt(process.env.CONCURRENCY ?? "1", 10) const concurrency = parseInt(process.env.CONCURRENCY ?? '1', 10)
const retryAfterMillis = process.env.RETRY_AFTER_MILLIS ? parseInt(process.env.RETRY_AFTER_MILLIS, 10) : null const retryAfterMillis = process.env.RETRY_AFTER_MILLIS
const maxCacheSize = parseInt(process.env.MAX_CACHE_SIZE ?? "100000", 10) ? parseInt(process.env.RETRY_AFTER_MILLIS, 10)
const logLevel = process.env.LOG_LEVEL ?? "debug" : null
const maxCacheSize = parseInt(process.env.MAX_CACHE_SIZE ?? '100000', 10)
const logLevel = process.env.LOG_LEVEL ?? 'debug'
const server = fastify({ const server = fastify({
logger: { logger: {
name: "app", name: 'app',
level: logLevel, level: logLevel,
...( logLevel == "trace" ? { transport: { target: 'pino-pretty' } } : {}) ...(logLevel == 'trace' ? { transport: { target: 'pino-pretty' } } : {})
} as PinoLoggerOptions } as PinoLoggerOptions
}) })
const log = server.log as Logger const log = server.log as Logger
const proxy = new Proxy(log, baseUrl, concurrency, retryAfterMillis, maxCacheSize) const proxy = new Proxy(
log,
baseUrl,
concurrency,
retryAfterMillis,
maxCacheSize
)
async function main() { async function main() {
server.register(
(fastify: FastifyInstance, opts, done) => {
fastify.get(
`/user/:username`,
{},
async (request: FastifyRequest, reply: FastifyReply) => {
log.debug(
{
headers: request.headers,
reqId: request.id,
params: request.params
},
'incoming request /user/:username'
)
const { username } = request.params as any
const { status, data } = await proxy.getUser(username, {
reqId: request.id
})
reply.status(status).send(data)
}
)
server.register((fastify: FastifyInstance, opts, done) => { fastify.get(
`/user/:userId/tweets`,
{},
async (request: FastifyRequest, reply: FastifyReply) => {
const { userId } = request.params as any
const { cursor } = request.query as any
const { status, data } = await proxy.getUserTweets(userId, cursor, {
reqId: request.id
})
reply.status(status).send(data)
}
)
fastify.get(`/user/:username`, {}, fastify.get(
async (request: FastifyRequest, reply: FastifyReply) => { `/tweet/:id`,
log.debug({ {},
headers: request.headers, async (request: FastifyRequest, reply: FastifyReply) => {
reqId: request.id, const { id } = request.params as any
params: request.params }, 'incoming request /user/:username') const { status, data } = await proxy.getTweetById(id, {
const { username } = request.params as any reqId: request.id
const { status, data } = await proxy.getUser(username, { reqId: request.id }) })
reply.status(status).send(data) reply.status(status).send(data)
}); }
)
fastify.get(`/user/:userId/tweets`, {}, done()
async (request: FastifyRequest, reply: FastifyReply) => { },
const { userId } = request.params as any { prefix: '/api' }
const { cursor } = request.query as any )
const { status, data } = await proxy.getUserTweets(userId, cursor, { reqId: request.id })
reply.status(status).send(data)
});
fastify.get(`/tweet/:id`, {}, server.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => {
async (request: FastifyRequest, reply: FastifyReply) => { reply.status(404).send({ message: `Method not found` })
const { id } = request.params as any })
const { status, data } = await proxy.getTweetById(id, { reqId: request.id })
reply.status(status).send(data)
});
done() server.setErrorHandler(
(err: Error, request: FastifyRequest, reply: FastifyReply) => {
const { log } = request
log.error(err)
// Send error response
reply.status(500).send({ message: `Internal server error` })
}
)
}, { prefix: '/api' }) // await server.register(import('@fastify/rate-limit'), {
// max: 100,
// timeWindow: '1 minute'
// })
server.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { await server.listen({ port, host } as FastifyListenOptions)
reply.status(404)
.send({ message: `Method not found` })
})
server.setErrorHandler((err: Error, request: FastifyRequest, reply: FastifyReply) => {
const { log } = request
log.error(err)
// Send error response
reply.status(500).send({ message: `Internal server error` })
})
// await server.register(import('@fastify/rate-limit'), {
// max: 100,
// timeWindow: '1 minute'
// })
await server.listen({ port, host } as FastifyListenOptions);
} }
main().catch(err => { main().catch((err) => {
log.fatal(err) log.fatal(err)
process.exit(1) process.exit(1)
}) })

View file

@ -1,209 +1,226 @@
// noinspection TypeScriptUnresolvedReference // noinspection TypeScriptUnresolvedReference
import axios from "axios" import axios from 'axios'
import { AxiosInstance, AxiosRequestConfig } from "axios" import { AxiosInstance, AxiosRequestConfig } from 'axios'
import fastq from "fastq" import fastq from 'fastq'
import { Logger } from "pino" import { Logger } from 'pino'
import retry from "axios-retry-after" import retry from 'axios-retry-after'
import { LRUCache } from 'lru-cache' import { LRUCache } from 'lru-cache'
const GET_USER_POSITIVE_TTL_MS = process.env.GET_USER_POSITIVE_TTL const GET_USER_POSITIVE_TTL_MS = process.env.GET_USER_POSITIVE_TTL
? parseInt(process.env.GET_USER_POSITIVE_TTL, 10) * 1000 ? parseInt(process.env.GET_USER_POSITIVE_TTL, 10) * 1000
: 30 * 24 * 3600 * 1000 : 30 * 24 * 3600 * 1000
const GET_USER_NEGATIVE_TTL_MS = process.env.GET_USER_NEGATIVE_TTL const GET_USER_NEGATIVE_TTL_MS = process.env.GET_USER_NEGATIVE_TTL
? parseInt(process.env.GET_USER_NEGATIVE_TTL, 10) * 1000 ? parseInt(process.env.GET_USER_NEGATIVE_TTL, 10) * 1000
: 3600 * 1000 : 3600 * 1000
const GET_TWEETS_POSITIVE_TTL_MS = process.env.GET_TWEETS_POSITIVE_TTL const GET_TWEETS_POSITIVE_TTL_MS = process.env.GET_TWEETS_POSITIVE_TTL
? parseInt(process.env.GET_TWEETS_POSITIVE_TTL, 10) * 1000 ? parseInt(process.env.GET_TWEETS_POSITIVE_TTL, 10) * 1000
: 60 * 1000 : 60 * 1000
const GET_TWEETS_NEGATIVE_TTL_MS = process.env.GET_TWEETS_NEGATIVE_TTL const GET_TWEETS_NEGATIVE_TTL_MS = process.env.GET_TWEETS_NEGATIVE_TTL
? parseInt(process.env.GET_TWEETS_NEGATIVE_TTL, 10) * 1000 ? parseInt(process.env.GET_TWEETS_NEGATIVE_TTL, 10) * 1000
: 60 * 1000 : 60 * 1000
const GET_TWEET_POSITIVE_TTL_MS = process.env.GET_TWEET_POSITIVE_TTL const GET_TWEET_POSITIVE_TTL_MS = process.env.GET_TWEET_POSITIVE_TTL
? parseInt(process.env.GET_TWEET_POSITIVE_TTL, 10) * 1000 ? parseInt(process.env.GET_TWEET_POSITIVE_TTL, 10) * 1000
: 60 * 1000 : 60 * 1000
const GET_TWEET_NEGATIVE_TTL_MS = process.env.GET_TWEET_NEGATIVE_TTL const GET_TWEET_NEGATIVE_TTL_MS = process.env.GET_TWEET_NEGATIVE_TTL
? parseInt(process.env.GET_TWEET_NEGATIVE_TTL, 10) * 1000 ? parseInt(process.env.GET_TWEET_NEGATIVE_TTL, 10) * 1000
: 60 * 1000 : 60 * 1000
export interface Job { export interface Job {
reqId: string reqId: string
url: string url: string
params?: Record<string, any> params?: Record<string, any>
} }
export interface JobResponse { export interface JobResponse {
status: number, status: number
data: any data: any
} }
export class Proxy { export class Proxy {
private readonly cache: LRUCache<string, JobResponse>
private readonly client: AxiosInstance
private readonly queue: fastq.queueAsPromised<Job, JobResponse>
private counter: { requests: number }
private timeWindowMillis = 15 * 60 * 1000
private maxRequestsPerAccount = 15 * 60
private readonly cache: LRUCache<string, JobResponse> constructor(
private readonly client: AxiosInstance private log: Logger,
private readonly queue: fastq.queueAsPromised<Job, JobResponse> private baseUrl: string,
private counter: { requests: number } private concurrency: number,
private timeWindowMillis = 15 * 60 * 1000 retryAfterMillis: number,
private maxRequestsPerAccount = 15 * 60 maxCacheSize: number
) {
constructor( this.cache = new LRUCache({ max: maxCacheSize })
private log: Logger, this.queue = fastq.promise(this, this.sendRequest, concurrency)
private baseUrl: string, this.client = axios.create()
private concurrency: number, this.counter = {
retryAfterMillis: number, requests: 0
maxCacheSize: number
) {
this.cache = new LRUCache({ max: maxCacheSize })
this.queue = fastq.promise(this, this.sendRequest, concurrency)
this.client = axios.create()
this.counter = {
requests: 0
}
setInterval(() => {
this.counter.requests = 0
}, this.timeWindowMillis)
if ( retryAfterMillis ) {
this.client.interceptors.response.use(null, retry(this.client, {
// Determine when we should attempt to retry
isRetryable (error) {
log.debug({ status: error.response?.status, headers: error.response?.headers }, 'checking retryable')
return (
error.response && error.response.status === 429
// Use X-Retry-After rather than Retry-After, and cap retry delay at 60 seconds
// && error.response.headers['x-retry-after'] && error.response.headers['x-retry-after'] <= 60
)
},
// Customize the wait behavior
wait (error) {
log.debug({ status: error.response?.status, headers: error.response?.headers }, 'waiting for retry')
return new Promise(
// Use X-Retry-After rather than Retry-After
// resolve => setTimeout(resolve, error.response.headers['x-retry-after'])
resolve => setTimeout(resolve, retryAfterMillis)
)
}
}))
}
} }
async getUser(username: string, options?: { reqId?: string }) { setInterval(() => {
const key = `usernames:${username}` this.counter.requests = 0
}, this.timeWindowMillis)
if ( this.cache.has(key)) { if (retryAfterMillis) {
return this.cache.get(key) this.client.interceptors.response.use(
} null,
retry(this.client, {
const result = await this.queue.push({ // Determine when we should attempt to retry
url: `/api/user/${ username }`, isRetryable(error) {
reqId: options?.reqId log.debug(
{
status: error.response?.status,
headers: error.response?.headers
},
'checking retryable'
)
return (
error.response && error.response.status === 429
// Use X-Retry-After rather than Retry-After, and cap retry delay at 60 seconds
// && error.response.headers['x-retry-after'] && error.response.headers['x-retry-after'] <= 60
)
},
// Customize the wait behavior
wait(error) {
log.debug(
{
status: error.response?.status,
headers: error.response?.headers
},
'waiting for retry'
)
return new Promise(
// Use X-Retry-After rather than Retry-After
// resolve => setTimeout(resolve, error.response.headers['x-retry-after'])
(resolve) => setTimeout(resolve, retryAfterMillis)
)
}
}) })
)
}
}
if ( result.status === 200 ) { async getUser(username: string, options?: { reqId?: string }) {
this.cache.set(key, result, { ttl: GET_USER_POSITIVE_TTL_MS }) const key = `usernames:${username}`
}
if ( result.status === 404 ) {
this.cache.set(key, result, { ttl: GET_USER_NEGATIVE_TTL_MS })
}
return result if (this.cache.has(key)) {
return this.cache.get(key)
} }
async getUserTweets(userId: string, cursor?: string, options?: { reqId?: string }) { const result = await this.queue.push({
const key = `users:${userId}:tweets:${cursor ?? 'last'}` url: `/api/user/${username}`,
reqId: options?.reqId
})
if ( this.cache.has(key) ) { if (result.status === 200) {
return this.cache.get(key) this.cache.set(key, result, { ttl: GET_USER_POSITIVE_TTL_MS })
} }
if (result.status === 404) {
const result = await this.queue.push({ this.cache.set(key, result, { ttl: GET_USER_NEGATIVE_TTL_MS })
url: `/api/user/${ userId }/tweets`,
params: { cursor },
reqId: options?.reqId
})
if ( result.status === 200 ) {
this.cache.set(key, result, { ttl: GET_TWEETS_POSITIVE_TTL_MS })
}
if ( result.status === 404 ) {
this.cache.set(key, result, { ttl: GET_TWEETS_NEGATIVE_TTL_MS })
}
return result
} }
async getTweetById(tweetId: string, options?: { reqId?: string }) { return result
const key = `tweets:${tweetId}` }
if ( this.cache.has(key) ) { async getUserTweets(
return this.cache.get(key) userId: string,
} cursor?: string,
options?: { reqId?: string }
) {
const key = `users:${userId}:tweets:${cursor ?? 'last'}`
const result = await this.queue.push({ if (this.cache.has(key)) {
url: `/api/tweet/${ tweetId }`, return this.cache.get(key)
reqId: options?.reqId
})
if ( result.status === 200 ) {
this.cache.set(key, result, { ttl: GET_TWEET_POSITIVE_TTL_MS })
}
if ( result.status === 404 ) {
this.cache.set(key, result, { ttl: GET_TWEET_NEGATIVE_TTL_MS })
}
return result
} }
private async sendRequest(job: Job): Promise<any> { const result = await this.queue.push({
url: `/api/user/${userId}/tweets`,
params: { cursor },
reqId: options?.reqId
})
const { reqId, url, params } = job if (result.status === 200) {
this.cache.set(key, result, { ttl: GET_TWEETS_POSITIVE_TTL_MS })
if ( this.counter.requests > this.concurrency * this.maxRequestsPerAccount ) {
return {
status: 429
}
}
let config = {
url,
method: "get",
baseURL: this.baseUrl,
params,
} as AxiosRequestConfig
this.log.trace({ config, reqId: reqId }, 'sending request to nitter')
try {
const response = await this.client.request(config)
this.log.trace({
status: response.status,
data: response.data,
reqId: reqId
}, 'nitter response')
return {
status: response.status,
data: response.data,
} as JobResponse
} catch(err) {
this.log.warn({ err, reqId }, 'nitter error')
if ( err.name === "AxiosError" ) {
this.counter.requests = Number.MAX_SAFE_INTEGER
return {
status: 429
} as JobResponse
}
return {
status: 500
}
}
} }
if (result.status === 404) {
this.cache.set(key, result, { ttl: GET_TWEETS_NEGATIVE_TTL_MS })
}
return result
}
async getTweetById(tweetId: string, options?: { reqId?: string }) {
const key = `tweets:${tweetId}`
if (this.cache.has(key)) {
return this.cache.get(key)
}
const result = await this.queue.push({
url: `/api/tweet/${tweetId}`,
reqId: options?.reqId
})
if (result.status === 200) {
this.cache.set(key, result, { ttl: GET_TWEET_POSITIVE_TTL_MS })
}
if (result.status === 404) {
this.cache.set(key, result, { ttl: GET_TWEET_NEGATIVE_TTL_MS })
}
return result
}
private async sendRequest(job: Job): Promise<any> {
const { reqId, url, params } = job
if (this.counter.requests > this.concurrency * this.maxRequestsPerAccount) {
return {
status: 429
}
}
let config = {
url,
method: 'get',
baseURL: this.baseUrl,
params
} as AxiosRequestConfig
this.log.trace({ config, reqId: reqId }, 'sending request to nitter')
try {
const response = await this.client.request(config)
this.log.trace(
{
status: response.status,
data: response.data,
reqId: reqId
},
'nitter response'
)
return {
status: response.status,
data: response.data
} as JobResponse
} catch (err) {
this.log.warn({ err, reqId }, 'nitter error')
if (err.name === 'AxiosError') {
this.counter.requests = Number.MAX_SAFE_INTEGER
return {
status: 429
} as JobResponse
}
return {
status: 500
}
}
}
} }

58
proxy/src/types.d.ts vendored
View file

@ -1,39 +1,37 @@
declare module 'axios-retry-after' { declare module 'axios-retry-after' {
import { AxiosError, AxiosInstance } from 'axios'
import { AxiosError, AxiosInstance } from "axios"; /**
* Function to enhance Axios instance with retry-after functionality.
* @param axios Axios instance to be enhanced.
* @param options Configuration options for retry behavior.
*/
export default function (
axios: AxiosInstance,
options?: AxiosRetryAfterOptions
): (error: AxiosError) => Promise<void>
/**
* Configuration options for axios-retry-after.
*/
export interface AxiosRetryAfterOptions {
/**
* Function to determine if an error response is retryable.
* @param error The Axios error to evaluate.
*/
isRetryable?: (error: AxiosError) => boolean
/** /**
* Function to enhance Axios instance with retry-after functionality. * Function to wait for a specified amount of time.
* @param axios Axios instance to be enhanced. * @param error The Axios error that contains retry-after header.
* @param options Configuration options for retry behavior.
*/ */
export default function( wait?: (error: AxiosError) => Promise<void>
axios: AxiosInstance,
options?: AxiosRetryAfterOptions
): (error: AxiosError) => Promise<void>;
/** /**
* Configuration options for axios-retry-after. * Function to retry the original request.
* @param axios The Axios instance used for retrying the request.
* @param error The Axios error to retry.
*/ */
export interface AxiosRetryAfterOptions { retry?: (axios: AxiosInstance, error: AxiosError) => Promise<any>
/** }
* Function to determine if an error response is retryable.
* @param error The Axios error to evaluate.
*/
isRetryable?: (error: AxiosError) => boolean;
/**
* Function to wait for a specified amount of time.
* @param error The Axios error that contains retry-after header.
*/
wait?: (error: AxiosError) => Promise<void>;
/**
* Function to retry the original request.
* @param axios The Axios instance used for retrying the request.
* @param error The Axios error to retry.
*/
retry?: (axios: AxiosInstance, error: AxiosError) => Promise<any>;
}
} }

View file

@ -12,7 +12,5 @@
"outDir": "./build" "outDir": "./build"
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"exclude": [ "exclude": ["node_modules"]
"node_modules"
]
} }

6
public/css/baguetteBox.min.css vendored Normal file
View file

@ -0,0 +1,6 @@
/*!
* baguetteBox.js
* @author feimosi
* @version 1.11.1
* @url https://github.com/feimosi/baguetteBox.js
*/#baguetteBox-overlay{display:none;opacity:0;position:fixed;overflow:hidden;top:0;left:0;width:100%;height:100%;z-index:1000000;background-color:#222;background-color:rgba(0,0,0,.8);-webkit-transition:opacity .5s ease;transition:opacity .5s ease}#baguetteBox-overlay.visible{opacity:1}#baguetteBox-overlay .full-image{display:inline-block;position:relative;width:100%;height:100%;text-align:center}#baguetteBox-overlay .full-image figure{display:inline;margin:0;height:100%}#baguetteBox-overlay .full-image img{display:inline-block;width:auto;height:auto;max-height:100%;max-width:100%;vertical-align:middle;-webkit-box-shadow:0 0 8px rgba(0,0,0,.6);-moz-box-shadow:0 0 8px rgba(0,0,0,.6);box-shadow:0 0 8px rgba(0,0,0,.6)}#baguetteBox-overlay .full-image figcaption{display:block;position:absolute;bottom:0;width:100%;text-align:center;line-height:1.8;white-space:normal;color:#ccc;background-color:#000;background-color:rgba(0,0,0,.6);font-family:sans-serif}#baguetteBox-overlay .full-image:before{content:"";display:inline-block;height:50%;width:1px;margin-right:-1px}#baguetteBox-slider{position:absolute;left:0;top:0;height:100%;width:100%;white-space:nowrap;-webkit-transition:left .4s ease,-webkit-transform .4s ease;transition:left .4s ease,-webkit-transform .4s ease;transition:left .4s ease,transform .4s ease;transition:left .4s ease,transform .4s ease,-webkit-transform .4s ease,-moz-transform .4s ease}#baguetteBox-slider.bounce-from-right{-webkit-animation:bounceFromRight .4s ease-out;animation:bounceFromRight .4s ease-out}#baguetteBox-slider.bounce-from-left{-webkit-animation:bounceFromLeft .4s ease-out;animation:bounceFromLeft .4s ease-out}@-webkit-keyframes bounceFromRight{0%,100%{margin-left:0}50%{margin-left:-30px}}@keyframes bounceFromRight{0%,100%{margin-left:0}50%{margin-left:-30px}}@-webkit-keyframes bounceFromLeft{0%,100%{margin-left:0}50%{margin-left:30px}}@keyframes bounceFromLeft{0%,100%{margin-left:0}50%{margin-left:30px}}.baguetteBox-button#next-button,.baguetteBox-button#previous-button{top:50%;top:calc(50% - 30px);width:44px;height:60px}.baguetteBox-button{position:absolute;cursor:pointer;outline:0;padding:0;margin:0;border:0;-moz-border-radius:15%;border-radius:15%;background-color:#323232;background-color:rgba(50,50,50,.5);color:#ddd;font:1.6em sans-serif;-webkit-transition:background-color .4s ease;transition:background-color .4s ease}.baguetteBox-button:focus,.baguetteBox-button:hover{background-color:rgba(50,50,50,.9)}.baguetteBox-button#next-button{right:2%}.baguetteBox-button#previous-button{left:2%}.baguetteBox-button#close-button{top:20px;right:2%;right:calc(2% + 6px);width:30px;height:30px}.baguetteBox-button svg{position:absolute;left:0;top:0}.baguetteBox-spinner{width:40px;height:40px;display:inline-block;position:absolute;top:50%;left:50%;margin-top:-20px;margin-left:-20px}.baguetteBox-double-bounce1,.baguetteBox-double-bounce2{width:100%;height:100%;-moz-border-radius:50%;border-radius:50%;background-color:#fff;opacity:.6;position:absolute;top:0;left:0;-webkit-animation:bounce 2s infinite ease-in-out;animation:bounce 2s infinite ease-in-out}.baguetteBox-double-bounce2{-webkit-animation-delay:-1s;animation-delay:-1s}@-webkit-keyframes bounce{0%,100%{-webkit-transform:scale(0);transform:scale(0)}50%{-webkit-transform:scale(1);transform:scale(1)}}@keyframes bounce{0%,100%{-webkit-transform:scale(0);-moz-transform:scale(0);transform:scale(0)}50%{-webkit-transform:scale(1);-moz-transform:scale(1);transform:scale(1)}}

7
public/js/baguetteBox.min.js vendored Normal file

File diff suppressed because one or more lines are too long

3
public/js/zoom.js Normal file
View file

@ -0,0 +1,3 @@
window.addEventListener("load", function () {
baguetteBox.run(".attachments");
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 KiB

After

Width:  |  Height:  |  Size: 797 KiB

View file

@ -21,6 +21,9 @@ let
# Images aren't supported due to errors from Teddit when the image # Images aren't supported due to errors from Teddit when the image
# wasn't first displayed via a post on the Teddit instance. # wasn't first displayed via a post on the Teddit instance.
imgurRegex = re"((i|i.stack)\.)?imgur\.(com|io)"
mediumRegex = re"([a-zA-Z0-9_.-]+\.)?medium\.com"
wwwRegex = re"https?://(www[0-9]?\.)?" wwwRegex = re"https?://(www[0-9]?\.)?"
m3u8Regex = re"""url="(.+.m3u8)"""" m3u8Regex = re"""url="(.+.m3u8)""""
userPicRegex = re"_(normal|bigger|mini|200x200|400x400)(\.[A-z]+)$" userPicRegex = re"_(normal|bigger|mini|200x200|400x400)(\.[A-z]+)$"
@ -69,6 +72,12 @@ proc replaceUrls*(body: string; prefs: Prefs; absolute=""): string =
if prefs.replaceReddit in result and "/gallery/" in result: if prefs.replaceReddit in result and "/gallery/" in result:
result = result.replace("/gallery/", "/comments/") result = result.replace("/gallery/", "/comments/")
if prefs.replaceImgur.len > 0 and "imgur" in result:
result = result.replace(imgurRegex, prefs.replaceImgur)
if prefs.replaceMedium.len > 0 and "medium.com" in result:
result = result.replace(mediumRegex, prefs.replaceMedium)
if absolute.len > 0 and "href" in result: if absolute.len > 0 and "href" in result:
result = result.replace("href=\"/", &"href=\"{absolute}/") result = result.replace("href=\"/", &"href=\"{absolute}/")
@ -82,6 +91,8 @@ proc proxifyVideo*(manifest: string; proxy: bool): string =
for line in manifest.splitLines: for line in manifest.splitLines:
let url = let url =
if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2] if line.startsWith("#EXT-X-MAP:URI"): line[16 .. ^2]
elif line.startsWith("#EXT-X-MEDIA") and "URI=" in line:
line[line.find("URI=") + 5 .. -1 + line.find("\"", start= 5 + line.find("URI="))]
else: line else: line
if url.startsWith('/'): if url.startsWith('/'):
let path = "https://video.twimg.com" & url let path = "https://video.twimg.com" & url

View file

@ -11,7 +11,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth
import views/[general, about] import views/[general, about]
import routes/[ import routes/[
preferences, timeline, status, media, search, rss, list, debug, preferences, timeline, status, media, search, rss, list, debug,
unsupported, embed, resolver, router_utils] unsupported, embed, resolver, router_utils, home,follow]
const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances"
const issuesUrl = "https://github.com/zedeus/nitter/issues" const issuesUrl = "https://github.com/zedeus/nitter/issues"
@ -60,9 +60,6 @@ settings:
reusePort = true reusePort = true
routes: routes:
get "/":
resp renderMain(renderSearch(), request, cfg, themePrefs())
get "/about": get "/about":
resp renderMain(renderAbout(), request, cfg, themePrefs()) resp renderMain(renderAbout(), request, cfg, themePrefs())
@ -94,7 +91,9 @@ routes:
const link = a("another instance", href = instancesUrl) const link = a("another instance", href = instancesUrl)
resp Http429, showError( resp Http429, showError(
&"Instance has been rate limited.<br>Use {link} or try again later.", cfg) &"Instance has been rate limited.<br>Use {link} or try again later.", cfg)
extend home, ""
extend follow, ""
extend rss, "" extend rss, ""
extend status, "" extend status, ""
extend search, "" extend search, ""

View file

@ -50,6 +50,11 @@ macro genPrefs*(prefDsl: untyped) =
const `name`*: PrefList = toOrderedTable(`table`) const `name`*: PrefList = toOrderedTable(`table`)
genPrefs: genPrefs:
Timeline:
following(input, ""):
"A comma-separated list of users to follow."
placeholder: "taskylizard,vercel,nodejs"
Display: Display:
theme(select, "Nitter"): theme(select, "Nitter"):
"Theme" "Theme"
@ -107,6 +112,14 @@ genPrefs:
"Reddit -> Teddit/Libreddit" "Reddit -> Teddit/Libreddit"
placeholder: "Teddit hostname" placeholder: "Teddit hostname"
replaceImgur(input, ""):
"Imgur -> Rimgo"
placeholder: "Rimgo hostname"
replaceMedium(input, ""):
"Medium -> Scribe"
placeholder: "Scribe hostname"
iterator allPrefs*(): Pref = iterator allPrefs*(): Pref =
for k, v in prefList: for k, v in prefList:
for pref in v: for pref in v:

42
src/routes/follow.nim Normal file
View file

@ -0,0 +1,42 @@
import jester, asyncdispatch, strutils, sequtils
import router_utils
import ../types
export follow
proc addUserToFollowing*(following, toAdd: string): string =
var updated = following.split(",")
if updated == @[""]:
return toAdd
elif toAdd in updated:
return following
else:
updated = concat(updated, @[toAdd])
result = updated.join(",")
proc removeUserFromFollowing*(following, remove: string): string =
var updated = following.split(",")
if updated == @[""]:
return ""
else:
updated = filter(updated, proc(x: string): bool = x != remove)
result = updated.join(",")
proc createFollowRouter*(cfg: Config) =
router follow:
post "/follow/@name":
let
following = cookiePrefs().following
toAdd = @"name"
updated = addUserToFollowing(following, toAdd)
setCookie("following", updated, daysForward(if isEmptyOrWhitespace(updated): -10 else: 360),
httpOnly=true, secure=cfg.useHttps, path="/")
redirect(refPath())
post "/unfollow/@name":
let
following = cookiePrefs().following
remove = @"name"
updated = removeUserFromFollowing(following, remove)
setCookie("following", updated, daysForward(360),
httpOnly=true, secure=cfg.useHttps, path="/")
redirect(refPath())

49
src/routes/home.nim Normal file
View file

@ -0,0 +1,49 @@
import jester
import asyncdispatch, strutils, options, router_utils, timeline
import ".."/[prefs, types, utils, redis_cache]
import ../views/[general, home, search]
export home
proc showHome*(request: Request; query: Query; cfg: Config; prefs: Prefs;
after: string): Future[string] {.async.} =
let
timeline = await getGraphTweetSearch(query, after)
html = renderHome(timeline, prefs, getPath())
return renderMain(html, request, cfg, prefs)
proc createHomeRouter*(cfg: Config) =
router home:
get "/":
let
prefs = cookiePrefs()
after = getCursor()
names = getNames(prefs.following)
var query = request.getQuery("", prefs.following)
query.fromUser = names
if @"scroll".len > 0:
var timeline = await getGraphTweetSearch(query, after)
if timeline.content.len == 0: resp Http404
timeline.beginning = true
resp $renderHome(timeline, prefs, getPath())
if names.len == 0:
resp renderMain(renderSearch(), request, cfg, themePrefs())
resp (await showHome(request, query, cfg, prefs, after))
get "/following":
let
prefs = cookiePrefs()
names = getNames(prefs.following)
var
profs: seq[User]
query = request.getQuery("", prefs.following)
query.fromUser = names
query.kind = userList
for name in names:
let prof = await getCachedUser(name)
profs &= @[prof]
resp renderMain(renderFollowing(query, profs, prefs), request, cfg, prefs)

View file

@ -1,130 +1,139 @@
@import '_variables'; @import "_variables";
@import '_mixins'; @import "_mixins";
.profile-card { .profile-card {
flex-wrap: wrap; flex-wrap: wrap;
background: var(--bg_panel); background: var(--bg_panel);
padding: 12px; padding: 12px;
display: flex; display: flex;
} }
.profile-card-info { .profile-card-info {
@include breakable; @include breakable;
width: 100%; width: 100%;
} }
.profile-card-tabs-name { .profile-card-tabs-name-and-follow {
@include breakable; @include breakable;
max-width: 100%; width: 100%;
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.profile-card-follow-button {
float: none;
} }
.profile-card-username { .profile-card-username {
@include breakable; @include breakable;
color: var(--fg_color); color: var(--fg_color);
font-size: 14px; font-size: 14px;
display: block; display: block;
} }
.profile-card-fullname { .profile-card-fullname {
@include breakable; @include breakable;
color: var(--fg_color); color: var(--fg_color);
font-size: 16px; font-size: 16px;
font-weight: bold; font-weight: bold;
text-shadow: none; text-shadow: none;
max-width: 100%; max-width: 100%;
} }
.profile-card-avatar { .profile-card-avatar {
display: inline-block; display: inline-block;
position: relative; position: relative;
width: 100%;
margin-right: 4px;
margin-bottom: 6px;
&:after {
content: "";
display: block;
margin-top: 100%;
}
img {
box-sizing: border-box;
position: absolute;
width: 100%; width: 100%;
margin-right: 4px; height: 100%;
margin-bottom: 6px; border: 4px solid var(--darker_grey);
background: var(--bg_panel);
&:after { }
content: '';
display: block;
margin-top: 100%;
}
img {
box-sizing: border-box;
position: absolute;
width: 100%;
height: 100%;
border: 4px solid var(--darker_grey);
background: var(--bg_panel);
}
} }
.profile-card-extra { .profile-card-extra {
display: contents; display: contents;
flex: 100%; flex: 100%;
margin-top: 7px; margin-top: 7px;
.profile-bio { .profile-bio {
@include breakable; @include breakable;
width: 100%; width: 100%;
margin: 4px -6px 6px 0; margin: 4px -6px 6px 0;
white-space: pre-wrap; white-space: pre-wrap;
p { p {
margin: 0; margin: 0;
}
} }
}
.profile-joindate, .profile-location, .profile-website { .profile-joindate,
color: var(--fg_faded); .profile-location,
margin: 1px 0; .profile-website {
width: 100%; color: var(--fg_faded);
} margin: 1px 0;
width: 100%;
}
} }
.profile-card-extra-links { .profile-card-extra-links {
margin-top: 8px; margin-top: 8px;
font-size: 14px; font-size: 14px;
width: 100%; width: 100%;
} }
.profile-statlist { .profile-statlist {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
padding: 0; padding: 0;
width: 100%; width: 100%;
justify-content: space-between; justify-content: space-between;
li { li {
display: table-cell; display: table-cell;
text-align: center; text-align: center;
} }
} }
.profile-stat-header { .profile-stat-header {
font-weight: bold; font-weight: bold;
color: var(--profile_stat); color: var(--profile_stat);
} }
.profile-stat-num { .profile-stat-num {
display: block; display: block;
color: var(--profile_stat); color: var(--profile_stat);
} }
@media(max-width: 700px) { @media (max-width: 700px) {
.profile-card-info { .profile-card-info {
display: flex; display: flex;
} }
.profile-card-tabs-name { .profile-card-tabs-name {
flex-shrink: 100; flex-shrink: 100;
} }
.profile-card-avatar { .profile-card-avatar {
width: 80px; width: 98px;
height: 80px; height: auto;
img { img {
border-width: 2px; border-width: 2px;
width: unset; width: unset;
}
} }
}
} }

View file

@ -52,8 +52,11 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc="";
let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" let opensearchUrl = getUrlPrefix(cfg) & "/opensearch"
buildHtml(head): buildHtml(head):
link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") link(rel="stylesheet", type="text/css", href="/css/style.css?v=20")
link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2") link(rel="stylesheet", type="text/css", href="/css/fontello.css?v=2")
link(rel="stylesheet", href="/css/baguetteBox.min.css")
script(src="/js/baguetteBox.min.js", `async`="")
script(src="/js/zoom.js")
if theme.len > 0: if theme.len > 0:
link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css"))

32
src/views/home.nim Normal file
View file

@ -0,0 +1,32 @@
import karax/[karaxdsl, vdom]
import search, timeline, renderutils
import ../types
proc renderFollowingUsers*(results: seq[User]; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline")):
for user in results:
renderUser(user, prefs)
proc renderHomeTabs*(query: Query): VNode =
buildHtml(ul(class="tab")):
li(class=query.getTabClass(posts)):
a(href="/"): text "Tweets"
li(class=query.getTabClass(userList)):
a(href=("/following")): text "Following"
proc renderHome*(results: Timeline; prefs: Prefs; path: string): VNode =
let query = results.query
buildHtml(tdiv(class="timeline-container")):
if query.fromUser.len > 0:
renderHomeTabs(query)
if query.fromUser.len == 0 or query.kind == tweets:
tdiv(class="timeline-header"):
renderSearchPanel(query)
renderTimelineTweets(results, prefs, path)
proc renderFollowing*(query: Query; following: seq[User]; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-container")):
renderHomeTabs(query)
renderFollowingUsers(following, prefs)

View file

@ -12,8 +12,8 @@ proc renderStat(num: int; class: string; text=""): VNode =
span(class="profile-stat-num"): span(class="profile-stat-num"):
text insertSep($num, ',') text insertSep($num, ',')
proc renderUserCard*(user: User; prefs: Prefs): VNode = proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode =
buildHtml(tdiv(class="profile-card")): buildHtml(tdiv(class="profile-card", "data-profile-id" = $user.id)):
tdiv(class="profile-card-info"): tdiv(class="profile-card-info"):
let let
url = getPicUrl(user.getUserPic()) url = getPicUrl(user.getUserPic())
@ -24,9 +24,15 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode =
a(class="profile-card-avatar", href=url, target="_blank"): a(class="profile-card-avatar", href=url, target="_blank"):
genImg(user.getUserPic(size)) genImg(user.getUserPic(size))
tdiv(class="profile-card-tabs-name"): tdiv(class="profile-card-tabs-name-and-follow"):
linkUser(user, class="profile-card-fullname") tdiv():
linkUser(user, class="profile-card-username") linkUser(user, class="profile-card-fullname")
linkUser(user, class="profile-card-username")
let following = isFollowing(user.username, prefs.following)
if not following:
buttonReferer "/follow/" & user.username, "Follow", path, "profile-card-follow-button"
else:
buttonReferer "/unfollow/" & user.username, "Unfollow", path, "profile-card-follow-button"
tdiv(class="profile-card-extra"): tdiv(class="profile-card-extra"):
if user.bio.len > 0: if user.bio.len > 0:
@ -113,7 +119,7 @@ proc renderProfile*(profile: var Profile; cfg: Config; prefs: Prefs; path: strin
let sticky = if prefs.stickyProfile: " sticky" else: "" let sticky = if prefs.stickyProfile: " sticky" else: ""
tdiv(class=("profile-tab" & sticky)): tdiv(class=("profile-tab" & sticky)):
renderUserCard(profile.user, prefs) renderUserCard(profile.user, prefs, path)
if profile.photoRail.len > 0: if profile.photoRail.len > 0:
renderPhotoRail(profile) renderPhotoRail(profile)

View file

@ -100,3 +100,7 @@ proc getTabClass*(query: Query; tab: QueryKind): string =
proc getAvatarClass*(prefs: Prefs): string = proc getAvatarClass*(prefs: Prefs): string =
if prefs.squareAvatars: "avatar" if prefs.squareAvatars: "avatar"
else: "avatar round" else: "avatar round"
proc isFollowing*(name, following: string): bool =
let following = following.split(",")
return name in following

View file

@ -33,10 +33,6 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
#let urlPrefix = getUrlPrefix(cfg) #let urlPrefix = getUrlPrefix(cfg)
#let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix) #let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)
<p>${text.replace("\n", "<br>\n")}</p> <p>${text.replace("\n", "<br>\n")}</p>
#if tweet.quote.isSome and get(tweet.quote).available:
# let quoteLink = getLink(get(tweet.quote))
<p><a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
#end if
#if tweet.photos.len > 0: #if tweet.photos.len > 0:
# for photo in tweet.photos: # for photo in tweet.photos:
<img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" /> <img src="${urlPrefix}${getPicUrl(photo)}" style="max-width:250px;" />
@ -54,6 +50,12 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname}
<img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" /> <img src="${urlPrefix}${getPicUrl(card.image)}" style="max-width:250px;" />
# end if # end if
#end if #end if
#if tweet.quote.isSome and get(tweet.quote).available:
# let quoteLink = getLink(get(tweet.quote))
<hr/>
<p>Quoting: <a href="${urlPrefix}${quoteLink}">${cfg.hostname}${quoteLink}</a></p>
${renderRssTweet(get(tweet.quote), cfg)}
#end if
#end proc #end proc
# #
#proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string = #proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string =

View file

@ -55,7 +55,16 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode =
renderTweet(tweet, prefs, path, class=(header & "thread"), renderTweet(tweet, prefs, path, class=(header & "thread"),
index=i, last=(i == thread.high), showThread=show) index=i, last=(i == thread.high), showThread=show)
proc renderUser(user: User; prefs: Prefs): VNode = proc threadFilter(tweets: openArray[Tweet]; threads: openArray[int64]; it: Tweet): seq[Tweet] =
result = @[it]
if it.retweet.isSome or it.replyId in threads: return
for t in tweets:
if t.id == result[0].replyId:
result.insert t
elif t.replyId == result[0].id:
result.add t
proc renderUser*(user: User; prefs: Prefs): VNode =
buildHtml(tdiv(class="timeline-item")): buildHtml(tdiv(class="timeline-item")):
a(class="tweet-link", href=("/" & user.username)) a(class="tweet-link", href=("/" & user.username))
tdiv(class="tweet-body profile-result"): tdiv(class="tweet-body profile-result"):

View file

@ -188,7 +188,7 @@ proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode =
span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets)
a(href="/search?q=quoted_tweet_id:" & $tweet.id): a(href="/search?q=quoted_tweet_id:" & $tweet.id):
span(class="tweet-stat"): icon "quote", formatStat(stats.quotes) span(class="tweet-stat"): icon "quote", formatStat(stats.quotes)
a(href=getLink(tweet, false) & "/favoriters"): a():
span(class="tweet-stat"): icon "heart", formatStat(stats.likes) span(class="tweet-stat"): icon "heart", formatStat(stats.likes)
a(href=getLink(tweet)): a(href=getLink(tweet)):
if views.len > 0: if views.len > 0:

36
twitter_oauth.sh Normal file
View file

@ -0,0 +1,36 @@
#!/bin/bash
# Grab oauth token for use with Nitter (requires Twitter account).
# results: {"oauth_token":"xxxxxxxxxx-xxxxxxxxx","oauth_token_secret":"xxxxxxxxxxxxxxxxxxxxx"}
username=""
password=""
if [[ -z "$username" || -z "$password" ]]; then
echo "needs username and password"
exit 1
fi
bearer_token='AAAAAAAAAAAAAAAAAAAAAFXzAwAAAAAAMHCxpeSDG1gLNLghVe8d74hl6k4%3DRUMF4xAQLsbeBhTSRrCiQpJtxoGWeyHrDb5te2jpGskWDFW82F'
guest_token=$(curl -s -XPOST https://api.twitter.com/1.1/guest/activate.json -H "Authorization: Bearer ${bearer_token}" | jq -r '.guest_token')
base_url='https://api.twitter.com/1.1/onboarding/task.json'
header=(-H "Authorization: Bearer ${bearer_token}" -H "User-Agent: TwitterAndroid/10.21.1" -H "Content-Type: application/json" -H "X-Guest-Token: ${guest_token}")
# start flow
flow_1=$(curl -si -XPOST "${base_url}?flow_name=login" "${header[@]}")
# get 'att', now needed in headers, and 'flow_token' from flow_1
att=$(sed -En 's/^att: (.*)\r/\1/p' <<< "${flow_1}")
flow_token=$(sed -n '$p' <<< "${flow_1}" | jq -r .flow_token)
# username
token_2=$(curl -s -XPOST "${base_url}" -H "att: ${att}" "${header[@]}" \
-d '{"flow_token":"'"${flow_token}"'","subtask_inputs":[{"subtask_id":"LoginEnterUserIdentifierSSO","settings_list":{"setting_responses":[{"key":"user_identifier","response_data":{"text_data":{"result":"'"${username}"'"}}}],"link":"next_link"}}]}' | jq -r .flow_token)
# password
token_3=$(curl -s -XPOST "${base_url}" -H "att: ${att}" "${header[@]}" \
-d '{"flow_token":"'"${token_2}"'","subtask_inputs":[{"enter_password":{"password":"'"${password}"'","link":"next_link"},"subtask_id":"LoginEnterPassword"}]}' | jq -r .flow_token)
# finally print oauth_token and secret
curl -s -XPOST "${base_url}" -H "att: ${att}" "${header[@]}" \
-d '{"flow_token":"'"${token_3}"'","subtask_inputs":[{"check_logged_in_account":{"link":"AccountDuplicationCheck_false"},"subtask_id":"AccountDuplicationCheck"}]}' | \
jq -c '.subtasks[0]|if(.open_account) then {oauth_token: .open_account.oauth_token, oauth_token_secret: .open_account.oauth_token_secret} else empty end'