diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml deleted file mode 100644 index 765e7a0..0000000 --- a/.github/workflows/build-docker.yml +++ /dev/null @@ -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 diff --git a/.github/workflows/build-publish-docker.yml b/.github/workflows/build-publish-docker.yml new file mode 100644 index 0000000..d32a220 --- /dev/null +++ b/.github/workflows/build-publish-docker.yml @@ -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 diff --git a/README.md b/README.md index 4f8235d..d1b8fc9 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscW - Archiving tweets/profiles - Developer API +## New Features + +- Likes tab + ## Resources The wiki contains @@ -99,6 +103,12 @@ $ nimble md $ 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 Redis info in `nitter.conf`. To run Redis, either run `redis-server --daemonize yes`, or `systemctl enable --now redis` (or diff --git a/docker-compose.yml b/docker-compose.yml index 2c4625e..cbfb722 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,34 +1,14 @@ version: "3" -networks: - nitter: - 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: - build: . + image: ghcr.io/privacydevel/nitter:master container_name: nitter - hostname: nitter 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: - ./nitter.conf:/src/nitter.conf:Z,ro - - ./guest_accounts.json:/src/guest_accounts.json:Z,ro depends_on: - nitter-redis restart: unless-stopped @@ -43,8 +23,6 @@ services: - no-new-privileges:true cap_drop: - ALL - networks: - - nitter nitter-redis: image: redis:6-alpine @@ -64,8 +42,6 @@ services: - no-new-privileges:true cap_drop: - ALL - networks: - - nitter volumes: nitter-redis: diff --git a/proxy/.prettierignore b/proxy/.prettierignore new file mode 100644 index 0000000..18f1a04 --- /dev/null +++ b/proxy/.prettierignore @@ -0,0 +1,2 @@ +**/*.md +pnpm-lock.yaml diff --git a/proxy/.prettierrc.yaml b/proxy/.prettierrc.yaml new file mode 100644 index 0000000..88e0a15 --- /dev/null +++ b/proxy/.prettierrc.yaml @@ -0,0 +1,6 @@ +proseWrap: always +semi: false +singleQuote: true +printWidth: 80 +trailingComma: none +htmlWhitespaceSensitivity: ignore diff --git a/proxy/package.json b/proxy/package.json index 0092699..0aa594b 100644 --- a/proxy/package.json +++ b/proxy/package.json @@ -4,6 +4,7 @@ "scripts": { "clean": "rm -rf build", "prebuild": "npm run clean", + "format": "prettier -w --cache --check .", "build": "tsc --build" }, "author": "", @@ -22,6 +23,7 @@ }, "devDependencies": { "@types/node": "^20.12.12", - "dotenv": "^16.4.4" + "dotenv": "^16.4.4", + "prettier": "^3.2.5" } } diff --git a/proxy/pnpm-lock.yaml b/proxy/pnpm-lock.yaml index 2d53adc..1876821 100644 --- a/proxy/pnpm-lock.yaml +++ b/proxy/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: dotenv: specifier: ^16.4.4 version: 16.4.4 + prettier: + specifier: ^3.2.5 + version: 3.2.5 packages: @@ -292,6 +295,11 @@ packages: resolution: {integrity: sha512-Mz/gKiRyuXu4HnpHgi1YWdHQCoWMufapzooisvFn78zl4dZciAxS+YeRkUxXl1ee/SzU80YCz1zpECCh4oC6Aw==} hasBin: true + prettier@3.2.5: + resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==} + engines: {node: '>=14'} + hasBin: true + process-warning@2.3.2: resolution: {integrity: sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==} @@ -668,6 +676,8 @@ snapshots: sonic-boom: 3.8.0 thread-stream: 2.4.1 + prettier@3.2.5: {} + process-warning@2.3.2: {} process-warning@3.0.0: {} diff --git a/proxy/src/index.ts b/proxy/src/index.ts index 020c5ef..b7c121d 100644 --- a/proxy/src/index.ts +++ b/proxy/src/index.ts @@ -1,89 +1,116 @@ - import fastify, { - FastifyInstance, - FastifyListenOptions, - FastifyReply, - FastifyRequest, -} from "fastify" -import { PinoLoggerOptions } from "fastify/types/logger" -import { Proxy } from "./proxy" -import { Logger } from "pino" + FastifyInstance, + FastifyListenOptions, + FastifyReply, + FastifyRequest +} from 'fastify' +import { PinoLoggerOptions } from 'fastify/types/logger' +import { Proxy } from './proxy' +import { Logger } from 'pino' import 'dotenv/config' 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 concurrency = parseInt(process.env.CONCURRENCY ?? "1", 10) -const retryAfterMillis = process.env.RETRY_AFTER_MILLIS ? parseInt(process.env.RETRY_AFTER_MILLIS, 10) : null -const maxCacheSize = parseInt(process.env.MAX_CACHE_SIZE ?? "100000", 10) -const logLevel = process.env.LOG_LEVEL ?? "debug" +const concurrency = parseInt(process.env.CONCURRENCY ?? '1', 10) +const retryAfterMillis = process.env.RETRY_AFTER_MILLIS + ? parseInt(process.env.RETRY_AFTER_MILLIS, 10) + : null +const maxCacheSize = parseInt(process.env.MAX_CACHE_SIZE ?? '100000', 10) +const logLevel = process.env.LOG_LEVEL ?? 'debug' const server = fastify({ - logger: { - name: "app", - level: logLevel, - ...( logLevel == "trace" ? { transport: { target: 'pino-pretty' } } : {}) - } as PinoLoggerOptions + logger: { + name: 'app', + level: logLevel, + ...(logLevel == 'trace' ? { transport: { target: 'pino-pretty' } } : {}) + } as PinoLoggerOptions }) 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() { + 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`, {}, - 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) - }); + fastify.get( + `/tweet/:id`, + {}, + async (request: FastifyRequest, reply: FastifyReply) => { + const { id } = request.params as any + const { status, data } = await proxy.getTweetById(id, { + reqId: request.id + }) + reply.status(status).send(data) + } + ) - 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) - }); + done() + }, + { prefix: '/api' } + ) - fastify.get(`/tweet/:id`, {}, - async (request: FastifyRequest, reply: FastifyReply) => { - const { id } = request.params as any - const { status, data } = await proxy.getTweetById(id, { reqId: request.id }) - reply.status(status).send(data) - }); + server.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { + reply.status(404).send({ message: `Method not found` }) + }) - 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) => { - 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); + await server.listen({ port, host } as FastifyListenOptions) } -main().catch(err => { - log.fatal(err) - process.exit(1) +main().catch((err) => { + log.fatal(err) + process.exit(1) }) diff --git a/proxy/src/proxy.ts b/proxy/src/proxy.ts index 872f481..a9fb9e5 100644 --- a/proxy/src/proxy.ts +++ b/proxy/src/proxy.ts @@ -1,209 +1,226 @@ // noinspection TypeScriptUnresolvedReference -import axios from "axios" -import { AxiosInstance, AxiosRequestConfig } from "axios" -import fastq from "fastq" -import { Logger } from "pino" -import retry from "axios-retry-after" +import axios from 'axios' +import { AxiosInstance, AxiosRequestConfig } from 'axios' +import fastq from 'fastq' +import { Logger } from 'pino' +import retry from 'axios-retry-after' import { LRUCache } from 'lru-cache' const GET_USER_POSITIVE_TTL_MS = process.env.GET_USER_POSITIVE_TTL - ? parseInt(process.env.GET_USER_POSITIVE_TTL, 10) * 1000 - : 30 * 24 * 3600 * 1000 + ? parseInt(process.env.GET_USER_POSITIVE_TTL, 10) * 1000 + : 30 * 24 * 3600 * 1000 const GET_USER_NEGATIVE_TTL_MS = process.env.GET_USER_NEGATIVE_TTL - ? parseInt(process.env.GET_USER_NEGATIVE_TTL, 10) * 1000 - : 3600 * 1000 + ? parseInt(process.env.GET_USER_NEGATIVE_TTL, 10) * 1000 + : 3600 * 1000 const GET_TWEETS_POSITIVE_TTL_MS = process.env.GET_TWEETS_POSITIVE_TTL - ? parseInt(process.env.GET_TWEETS_POSITIVE_TTL, 10) * 1000 - : 60 * 1000 + ? parseInt(process.env.GET_TWEETS_POSITIVE_TTL, 10) * 1000 + : 60 * 1000 const GET_TWEETS_NEGATIVE_TTL_MS = process.env.GET_TWEETS_NEGATIVE_TTL - ? parseInt(process.env.GET_TWEETS_NEGATIVE_TTL, 10) * 1000 - : 60 * 1000 + ? parseInt(process.env.GET_TWEETS_NEGATIVE_TTL, 10) * 1000 + : 60 * 1000 const GET_TWEET_POSITIVE_TTL_MS = process.env.GET_TWEET_POSITIVE_TTL - ? parseInt(process.env.GET_TWEET_POSITIVE_TTL, 10) * 1000 - : 60 * 1000 + ? parseInt(process.env.GET_TWEET_POSITIVE_TTL, 10) * 1000 + : 60 * 1000 const GET_TWEET_NEGATIVE_TTL_MS = process.env.GET_TWEET_NEGATIVE_TTL - ? parseInt(process.env.GET_TWEET_NEGATIVE_TTL, 10) * 1000 - : 60 * 1000 + ? parseInt(process.env.GET_TWEET_NEGATIVE_TTL, 10) * 1000 + : 60 * 1000 export interface Job { - reqId: string - url: string - params?: Record + reqId: string + url: string + params?: Record } export interface JobResponse { - status: number, - data: any + status: number + data: any } export class Proxy { + private readonly cache: LRUCache + private readonly client: AxiosInstance + private readonly queue: fastq.queueAsPromised + private counter: { requests: number } + private timeWindowMillis = 15 * 60 * 1000 + private maxRequestsPerAccount = 15 * 60 - private readonly cache: LRUCache - private readonly client: AxiosInstance - private readonly queue: fastq.queueAsPromised - private counter: { requests: number } - private timeWindowMillis = 15 * 60 * 1000 - private maxRequestsPerAccount = 15 * 60 - - constructor( - private log: Logger, - private baseUrl: string, - private concurrency: number, - retryAfterMillis: number, - 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) - ) - } - })) - } + constructor( + private log: Logger, + private baseUrl: string, + private concurrency: number, + retryAfterMillis: number, + maxCacheSize: number + ) { + this.cache = new LRUCache({ max: maxCacheSize }) + this.queue = fastq.promise(this, this.sendRequest, concurrency) + this.client = axios.create() + this.counter = { + requests: 0 } - async getUser(username: string, options?: { reqId?: string }) { - const key = `usernames:${username}` + setInterval(() => { + this.counter.requests = 0 + }, this.timeWindowMillis) - if ( this.cache.has(key)) { - return this.cache.get(key) - } - - const result = await this.queue.push({ - url: `/api/user/${ username }`, - reqId: options?.reqId + 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) + ) + } }) + ) + } + } - if ( result.status === 200 ) { - this.cache.set(key, result, { ttl: GET_USER_POSITIVE_TTL_MS }) - } - if ( result.status === 404 ) { - this.cache.set(key, result, { ttl: GET_USER_NEGATIVE_TTL_MS }) - } + async getUser(username: string, options?: { reqId?: string }) { + const key = `usernames:${username}` - return result + if (this.cache.has(key)) { + return this.cache.get(key) } - async getUserTweets(userId: string, cursor?: string, options?: { reqId?: string }) { - const key = `users:${userId}:tweets:${cursor ?? 'last'}` + const result = await this.queue.push({ + url: `/api/user/${username}`, + reqId: options?.reqId + }) - if ( this.cache.has(key) ) { - return this.cache.get(key) - } - - const result = await this.queue.push({ - 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 + if (result.status === 200) { + this.cache.set(key, result, { ttl: GET_USER_POSITIVE_TTL_MS }) + } + if (result.status === 404) { + this.cache.set(key, result, { ttl: GET_USER_NEGATIVE_TTL_MS }) } - async getTweetById(tweetId: string, options?: { reqId?: string }) { - const key = `tweets:${tweetId}` + return result + } - if ( this.cache.has(key) ) { - return this.cache.get(key) - } + async getUserTweets( + userId: string, + cursor?: string, + options?: { reqId?: string } + ) { + const key = `users:${userId}:tweets:${cursor ?? 'last'}` - 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 + if (this.cache.has(key)) { + return this.cache.get(key) } - private async sendRequest(job: Job): Promise { + const result = await this.queue.push({ + url: `/api/user/${userId}/tweets`, + params: { cursor }, + reqId: options?.reqId + }) - 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 - } - } + 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 }) { + 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 { + 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 + } + } + } } diff --git a/proxy/src/types.d.ts b/proxy/src/types.d.ts index a0c18fa..cd9cf13 100644 --- a/proxy/src/types.d.ts +++ b/proxy/src/types.d.ts @@ -1,39 +1,37 @@ 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 + + /** + * 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. - * @param axios Axios instance to be enhanced. - * @param options Configuration options for retry behavior. + * Function to wait for a specified amount of time. + * @param error The Axios error that contains retry-after header. */ - export default function( - axios: AxiosInstance, - options?: AxiosRetryAfterOptions - ): (error: AxiosError) => Promise; + wait?: (error: AxiosError) => Promise /** - * 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 { - /** - * 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; - - /** - * 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; - } + retry?: (axios: AxiosInstance, error: AxiosError) => Promise + } } - diff --git a/proxy/tsconfig.json b/proxy/tsconfig.json index 12c5931..ee6e859 100644 --- a/proxy/tsconfig.json +++ b/proxy/tsconfig.json @@ -12,7 +12,5 @@ "outDir": "./build" }, "include": ["src/**/*"], - "exclude": [ - "node_modules" - ] + "exclude": ["node_modules"] } diff --git a/public/css/baguetteBox.min.css b/public/css/baguetteBox.min.css new file mode 100644 index 0000000..f1c5ca9 --- /dev/null +++ b/public/css/baguetteBox.min.css @@ -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)}} \ No newline at end of file diff --git a/public/js/baguetteBox.min.js b/public/js/baguetteBox.min.js new file mode 100644 index 0000000..a6c7b04 --- /dev/null +++ b/public/js/baguetteBox.min.js @@ -0,0 +1,7 @@ +/*! + * baguetteBox.js + * @author feimosi + * @version 1.11.1 + * @url https://github.com/feimosi/baguetteBox.js + */ +!function(e,t){"use strict";"function"==typeof define&&define.amd?define(t):"object"==typeof exports?module.exports=t():e.baguetteBox=t()}(this,function(){"use strict";var r,l,u,c,d,f='',g='',p='',b={},v={captions:!0,buttons:"auto",fullScreen:!1,noScrollbars:!1,bodyClass:"baguetteBox-open",titleTag:!1,async:!1,preload:2,animation:"slideIn",afterShow:null,afterHide:null,onChange:null,overlayBackgroundColor:"rgba(0,0,0,.8)"},m={},h=[],o=0,n=!1,i={},a=!1,y=/.+\.(gif|jpe?g|png|webp)/i,w={},k=[],s=null,x=function(e){-1!==e.target.id.indexOf("baguette-img")&&j()},E=function(e){e.stopPropagation?e.stopPropagation():e.cancelBubble=!0,D()},C=function(e){e.stopPropagation?e.stopPropagation():e.cancelBubble=!0,X()},B=function(e){e.stopPropagation?e.stopPropagation():e.cancelBubble=!0,j()},T=function(e){i.count++,1
',b.captions&&s){var u=J("figcaption");u.id="baguetteBox-figcaption-"+t,u.innerHTML=s,l.appendChild(u)}e.appendChild(l);var c=J("img");c.onload=function(){var e=document.querySelector("#baguette-img-"+t+" .baguetteBox-spinner");l.removeChild(e),!b.async&&n&&n()},c.setAttribute("src",r),c.alt=a&&a.alt||"",b.titleTag&&s&&(c.title=s),l.appendChild(c),b.async&&n&&n()}}function X(){return M(o+1)}function D(){return M(o-1)}function M(e,t){return!n&&0<=e&&e=k.length?(b.animation&&O("right"),!1):(q(o=e,function(){z(o),V(o)}),R(),b.onChange&&b.onChange(o,k.length),!0)}function O(e){l.className="bounce-from-"+e,setTimeout(function(){l.className=""},400)}function R(){var e=100*-o+"%";"fadeIn"===b.animation?(l.style.opacity=0,setTimeout(function(){m.transforms?l.style.transform=l.style.webkitTransform="translate3d("+e+",0,0)":l.style.left=e,l.style.opacity=1},400)):m.transforms?l.style.transform=l.style.webkitTransform="translate3d("+e+",0,0)":l.style.left=e}function z(e){e-o>=b.preload||q(e+1,function(){z(e+1)})}function V(e){o-e>=b.preload||q(e-1,function(){V(e-1)})}function U(e,t,n,o){e.addEventListener?e.addEventListener(t,n,o):e.attachEvent("on"+t,function(e){(e=e||window.event).target=e.target||e.srcElement,n(e)})}function W(e,t,n,o){e.removeEventListener?e.removeEventListener(t,n,o):e.detachEvent("on"+t,n)}function G(e){return document.getElementById(e)}function J(e){return document.createElement(e)}return[].forEach||(Array.prototype.forEach=function(e,t){for(var n=0;n 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: result = result.replace("href=\"/", &"href=\"{absolute}/") @@ -82,6 +91,8 @@ proc proxifyVideo*(manifest: string; proxy: bool): string = for line in manifest.splitLines: let url = 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 if url.startsWith('/'): let path = "https://video.twimg.com" & url diff --git a/src/nitter.nim b/src/nitter.nim index f976db2..62a6026 100644 --- a/src/nitter.nim +++ b/src/nitter.nim @@ -11,7 +11,7 @@ import types, config, prefs, formatters, redis_cache, http_pool, auth import views/[general, about] import routes/[ 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 issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -60,9 +60,6 @@ settings: reusePort = true routes: - get "/": - resp renderMain(renderSearch(), request, cfg, themePrefs()) - get "/about": resp renderMain(renderAbout(), request, cfg, themePrefs()) @@ -94,7 +91,9 @@ routes: const link = a("another instance", href = instancesUrl) resp Http429, showError( &"Instance has been rate limited.
Use {link} or try again later.", cfg) - + + extend home, "" + extend follow, "" extend rss, "" extend status, "" extend search, "" diff --git a/src/prefs_impl.nim b/src/prefs_impl.nim index 8e2ac8f..88c4e1e 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -50,6 +50,11 @@ macro genPrefs*(prefDsl: untyped) = const `name`*: PrefList = toOrderedTable(`table`) genPrefs: + Timeline: + following(input, ""): + "A comma-separated list of users to follow." + placeholder: "taskylizard,vercel,nodejs" + Display: theme(select, "Nitter"): "Theme" @@ -107,6 +112,14 @@ genPrefs: "Reddit -> Teddit/Libreddit" placeholder: "Teddit hostname" + replaceImgur(input, ""): + "Imgur -> Rimgo" + placeholder: "Rimgo hostname" + + replaceMedium(input, ""): + "Medium -> Scribe" + placeholder: "Scribe hostname" + iterator allPrefs*(): Pref = for k, v in prefList: for pref in v: diff --git a/src/routes/follow.nim b/src/routes/follow.nim new file mode 100644 index 0000000..1e5450d --- /dev/null +++ b/src/routes/follow.nim @@ -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()) diff --git a/src/routes/home.nim b/src/routes/home.nim new file mode 100644 index 0000000..dcdbb5e --- /dev/null +++ b/src/routes/home.nim @@ -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) diff --git a/src/sass/profile/card.scss b/src/sass/profile/card.scss index 46a9679..98790a3 100644 --- a/src/sass/profile/card.scss +++ b/src/sass/profile/card.scss @@ -1,130 +1,139 @@ -@import '_variables'; -@import '_mixins'; +@import "_variables"; +@import "_mixins"; .profile-card { - flex-wrap: wrap; - background: var(--bg_panel); - padding: 12px; - display: flex; + flex-wrap: wrap; + background: var(--bg_panel); + padding: 12px; + display: flex; } .profile-card-info { - @include breakable; - width: 100%; + @include breakable; + width: 100%; } -.profile-card-tabs-name { - @include breakable; - max-width: 100%; +.profile-card-tabs-name-and-follow { + @include breakable; + width: 100%; + display: flex; + flex-wrap: wrap; + justify-content: space-between; +} + +.profile-card-follow-button { + float: none; } .profile-card-username { - @include breakable; - color: var(--fg_color); - font-size: 14px; - display: block; + @include breakable; + color: var(--fg_color); + font-size: 14px; + display: block; } .profile-card-fullname { - @include breakable; - color: var(--fg_color); - font-size: 16px; - font-weight: bold; - text-shadow: none; - max-width: 100%; + @include breakable; + color: var(--fg_color); + font-size: 16px; + font-weight: bold; + text-shadow: none; + max-width: 100%; } .profile-card-avatar { - display: inline-block; - position: relative; + display: inline-block; + 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%; - margin-right: 4px; - margin-bottom: 6px; - - &: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); - } + height: 100%; + border: 4px solid var(--darker_grey); + background: var(--bg_panel); + } } .profile-card-extra { - display: contents; - flex: 100%; - margin-top: 7px; + display: contents; + flex: 100%; + margin-top: 7px; - .profile-bio { - @include breakable; - width: 100%; - margin: 4px -6px 6px 0; - white-space: pre-wrap; + .profile-bio { + @include breakable; + width: 100%; + margin: 4px -6px 6px 0; + white-space: pre-wrap; - p { - margin: 0; - } + p { + margin: 0; } + } - .profile-joindate, .profile-location, .profile-website { - color: var(--fg_faded); - margin: 1px 0; - width: 100%; - } + .profile-joindate, + .profile-location, + .profile-website { + color: var(--fg_faded); + margin: 1px 0; + width: 100%; + } } .profile-card-extra-links { - margin-top: 8px; - font-size: 14px; - width: 100%; + margin-top: 8px; + font-size: 14px; + width: 100%; } .profile-statlist { - display: flex; - flex-wrap: wrap; - padding: 0; - width: 100%; - justify-content: space-between; + display: flex; + flex-wrap: wrap; + padding: 0; + width: 100%; + justify-content: space-between; - li { - display: table-cell; - text-align: center; - } + li { + display: table-cell; + text-align: center; + } } .profile-stat-header { - font-weight: bold; - color: var(--profile_stat); + font-weight: bold; + color: var(--profile_stat); } .profile-stat-num { - display: block; - color: var(--profile_stat); + display: block; + color: var(--profile_stat); } -@media(max-width: 700px) { - .profile-card-info { - display: flex; - } +@media (max-width: 700px) { + .profile-card-info { + display: flex; + } - .profile-card-tabs-name { - flex-shrink: 100; - } + .profile-card-tabs-name { + flex-shrink: 100; + } - .profile-card-avatar { - width: 80px; - height: 80px; + .profile-card-avatar { + width: 98px; + height: auto; - img { - border-width: 2px; - width: unset; - } + img { + border-width: 2px; + width: unset; } + } } diff --git a/src/views/general.nim b/src/views/general.nim index 87d30f2..fb49f56 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -52,8 +52,11 @@ proc renderHead*(prefs: Prefs; cfg: Config; req: Request; titleText=""; desc=""; let opensearchUrl = getUrlPrefix(cfg) & "/opensearch" 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", href="/css/baguetteBox.min.css") + script(src="/js/baguetteBox.min.js", `async`="") + script(src="/js/zoom.js") if theme.len > 0: link(rel="stylesheet", type="text/css", href=(&"/css/themes/{theme}.css")) diff --git a/src/views/home.nim b/src/views/home.nim new file mode 100644 index 0000000..57acd2c --- /dev/null +++ b/src/views/home.nim @@ -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) diff --git a/src/views/profile.nim b/src/views/profile.nim index 2ec79f7..b89bf43 100644 --- a/src/views/profile.nim +++ b/src/views/profile.nim @@ -12,8 +12,8 @@ proc renderStat(num: int; class: string; text=""): VNode = span(class="profile-stat-num"): text insertSep($num, ',') -proc renderUserCard*(user: User; prefs: Prefs): VNode = - buildHtml(tdiv(class="profile-card")): +proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode = + buildHtml(tdiv(class="profile-card", "data-profile-id" = $user.id)): tdiv(class="profile-card-info"): let url = getPicUrl(user.getUserPic()) @@ -24,9 +24,15 @@ proc renderUserCard*(user: User; prefs: Prefs): VNode = a(class="profile-card-avatar", href=url, target="_blank"): genImg(user.getUserPic(size)) - tdiv(class="profile-card-tabs-name"): - linkUser(user, class="profile-card-fullname") - linkUser(user, class="profile-card-username") + tdiv(class="profile-card-tabs-name-and-follow"): + tdiv(): + 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"): 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: "" tdiv(class=("profile-tab" & sticky)): - renderUserCard(profile.user, prefs) + renderUserCard(profile.user, prefs, path) if profile.photoRail.len > 0: renderPhotoRail(profile) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index f298fad..86a6806 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -100,3 +100,7 @@ proc getTabClass*(query: Query; tab: QueryKind): string = proc getAvatarClass*(prefs: Prefs): string = if prefs.squareAvatars: "avatar" else: "avatar round" + +proc isFollowing*(name, following: string): bool = + let following = following.split(",") + return name in following diff --git a/src/views/rss.nimf b/src/views/rss.nimf index 036a7b9..1d40446 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -33,10 +33,6 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} #let urlPrefix = getUrlPrefix(cfg) #let text = replaceUrls(tweet.text, defaultPrefs, absolute=urlPrefix)

${text.replace("\n", "
\n")}

-#if tweet.quote.isSome and get(tweet.quote).available: -# let quoteLink = getLink(get(tweet.quote)) -

${cfg.hostname}${quoteLink}

-#end if #if tweet.photos.len > 0: # for photo in tweet.photos: @@ -54,6 +50,12 @@ Twitter feed for: ${desc}. Generated by ${cfg.hostname} # end if #end if +#if tweet.quote.isSome and get(tweet.quote).available: +# let quoteLink = getLink(get(tweet.quote)) +
+

Quoting: ${cfg.hostname}${quoteLink}

+${renderRssTweet(get(tweet.quote), cfg)} +#end if #end proc # #proc renderRssTweets(tweets: seq[Tweets]; cfg: Config; userId=""): string = diff --git a/src/views/timeline.nim b/src/views/timeline.nim index abeb6d3..305129b 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -55,7 +55,16 @@ proc renderThread(thread: Tweets; prefs: Prefs; path: string): VNode = renderTweet(tweet, prefs, path, class=(header & "thread"), 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")): a(class="tweet-link", href=("/" & user.username)) tdiv(class="tweet-body profile-result"): diff --git a/src/views/tweet.nim b/src/views/tweet.nim index 13b4a24..f890a55 100644 --- a/src/views/tweet.nim +++ b/src/views/tweet.nim @@ -188,7 +188,7 @@ proc renderStats(stats: TweetStats; views: string; tweet: Tweet): VNode = span(class="tweet-stat"): icon "retweet", formatStat(stats.retweets) a(href="/search?q=quoted_tweet_id:" & $tweet.id): 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) a(href=getLink(tweet)): if views.len > 0: diff --git a/twitter_oauth.sh b/twitter_oauth.sh new file mode 100644 index 0000000..f7cfba0 --- /dev/null +++ b/twitter_oauth.sh @@ -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' \ No newline at end of file