diff --git a/.github/workflows/build-docker.yml b/.github/workflows/build-docker.yml new file mode 100644 index 0000000..765e7a0 --- /dev/null +++ b/.github/workflows/build-docker.yml @@ -0,0 +1,62 @@ +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 deleted file mode 100644 index d32a220..0000000 --- a/.github/workflows/build-publish-docker.yml +++ /dev/null @@ -1,60 +0,0 @@ -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 d1b8fc9..4f8235d 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,6 @@ XMR: 42hKayRoEAw4D6G6t8mQHPJHQcXqofjFuVfavqKeNMNUZfeJLJAcNU19i1bGdDvcdN6romiSscW - Archiving tweets/profiles - Developer API -## New Features - -- Likes tab - ## Resources The wiki contains @@ -103,12 +99,6 @@ $ 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 cbfb722..2c4625e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,34 @@ 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: - image: ghcr.io/privacydevel/nitter:master + build: . container_name: nitter + hostname: nitter ports: - - "127.0.0.1:8080:8080" # Replace with "8080:8080" if you don't use a reverse proxy + - "8002: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 @@ -23,6 +43,8 @@ services: - no-new-privileges:true cap_drop: - ALL + networks: + - nitter nitter-redis: image: redis:6-alpine @@ -42,6 +64,8 @@ services: - no-new-privileges:true cap_drop: - ALL + networks: + - nitter volumes: nitter-redis: diff --git a/proxy/.prettierignore b/proxy/.prettierignore deleted file mode 100644 index 18f1a04..0000000 --- a/proxy/.prettierignore +++ /dev/null @@ -1,2 +0,0 @@ -**/*.md -pnpm-lock.yaml diff --git a/proxy/.prettierrc.yaml b/proxy/.prettierrc.yaml deleted file mode 100644 index 88e0a15..0000000 --- a/proxy/.prettierrc.yaml +++ /dev/null @@ -1,6 +0,0 @@ -proseWrap: always -semi: false -singleQuote: true -printWidth: 80 -trailingComma: none -htmlWhitespaceSensitivity: ignore diff --git a/proxy/package.json b/proxy/package.json index 0aa594b..0092699 100644 --- a/proxy/package.json +++ b/proxy/package.json @@ -4,7 +4,6 @@ "scripts": { "clean": "rm -rf build", "prebuild": "npm run clean", - "format": "prettier -w --cache --check .", "build": "tsc --build" }, "author": "", @@ -23,7 +22,6 @@ }, "devDependencies": { "@types/node": "^20.12.12", - "dotenv": "^16.4.4", - "prettier": "^3.2.5" + "dotenv": "^16.4.4" } } diff --git a/proxy/pnpm-lock.yaml b/proxy/pnpm-lock.yaml index 1876821..2d53adc 100644 --- a/proxy/pnpm-lock.yaml +++ b/proxy/pnpm-lock.yaml @@ -45,9 +45,6 @@ importers: dotenv: specifier: ^16.4.4 version: 16.4.4 - prettier: - specifier: ^3.2.5 - version: 3.2.5 packages: @@ -295,11 +292,6 @@ 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==} @@ -676,8 +668,6 @@ 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 b7c121d..020c5ef 100644 --- a/proxy/src/index.ts +++ b/proxy/src/index.ts @@ -1,116 +1,89 @@ + 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) - } - ) - 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) - } - ) + server.register((fastify: FastifyInstance, opts, done) => { - 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/: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) + }); - done() - }, - { prefix: '/api' } - ) + 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) + }); - server.setNotFoundHandler((request: FastifyRequest, reply: FastifyReply) => { - reply.status(404).send({ message: `Method not found` }) - }) + 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.setErrorHandler( - (err: Error, request: FastifyRequest, reply: FastifyReply) => { - const { log } = request - log.error(err) - // Send error response - reply.status(500).send({ message: `Internal server error` }) - } - ) + done() - // await server.register(import('@fastify/rate-limit'), { - // max: 100, - // timeWindow: '1 minute' - // }) + }, { prefix: '/api' }) - await server.listen({ port, host } as FastifyListenOptions) + 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); } -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 a9fb9e5..872f481 100644 --- a/proxy/src/proxy.ts +++ b/proxy/src/proxy.ts @@ -1,226 +1,209 @@ // 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 - 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 + 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) + ) + } + })) + } } - setInterval(() => { - this.counter.requests = 0 - }, this.timeWindowMillis) + async getUser(username: string, options?: { reqId?: string }) { + const key = `usernames:${username}` - 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 ( this.cache.has(key)) { + return this.cache.get(key) + } + + const result = await this.queue.push({ + url: `/api/user/${ username }`, + reqId: options?.reqId }) - ) - } - } - async getUser(username: string, options?: { reqId?: string }) { - const key = `usernames:${username}` + 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 }) + } - if (this.cache.has(key)) { - return this.cache.get(key) + return result } - const result = await this.queue.push({ - url: `/api/user/${username}`, - reqId: options?.reqId - }) + async getUserTweets(userId: string, cursor?: string, options?: { reqId?: string }) { + const key = `users:${userId}:tweets:${cursor ?? 'last'}` - 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 }) + 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 } - return result - } + async getTweetById(tweetId: string, options?: { reqId?: string }) { + const key = `tweets:${tweetId}` - async getUserTweets( - userId: string, - cursor?: string, - options?: { reqId?: string } - ) { - const key = `users:${userId}:tweets:${cursor ?? 'last'}` + if ( this.cache.has(key) ) { + return this.cache.get(key) + } - 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 } - const result = await this.queue.push({ - url: `/api/user/${userId}/tweets`, - params: { cursor }, - reqId: options?.reqId - }) + private async sendRequest(job: Job): Promise { - if (result.status === 200) { - this.cache.set(key, result, { ttl: GET_TWEETS_POSITIVE_TTL_MS }) + 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 === 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 cd9cf13..a0c18fa 100644 --- a/proxy/src/types.d.ts +++ b/proxy/src/types.d.ts @@ -1,37 +1,39 @@ declare module 'axios-retry-after' { - 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 + import { AxiosError, AxiosInstance } from "axios"; /** - * Function to wait for a specified amount of time. - * @param error The Axios error that contains retry-after header. + * Function to enhance Axios instance with retry-after functionality. + * @param axios Axios instance to be enhanced. + * @param options Configuration options for retry behavior. */ - wait?: (error: AxiosError) => Promise + export default function( + axios: AxiosInstance, + options?: AxiosRetryAfterOptions + ): (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. + * Configuration options for axios-retry-after. */ - retry?: (axios: AxiosInstance, error: AxiosError) => Promise - } + 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; + } } + diff --git a/proxy/tsconfig.json b/proxy/tsconfig.json index ee6e859..12c5931 100644 --- a/proxy/tsconfig.json +++ b/proxy/tsconfig.json @@ -12,5 +12,7 @@ "outDir": "./build" }, "include": ["src/**/*"], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] } diff --git a/public/css/baguetteBox.min.css b/public/css/baguetteBox.min.css deleted file mode 100644 index f1c5ca9..0000000 --- a/public/css/baguetteBox.min.css +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * 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 deleted file mode 100644 index a6c7b04..0000000 --- a/public/js/baguetteBox.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * 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}/") @@ -91,8 +82,6 @@ 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 62a6026..f976db2 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, home,follow] + unsupported, embed, resolver, router_utils] const instancesUrl = "https://github.com/zedeus/nitter/wiki/Instances" const issuesUrl = "https://github.com/zedeus/nitter/issues" @@ -60,6 +60,9 @@ settings: reusePort = true routes: + get "/": + resp renderMain(renderSearch(), request, cfg, themePrefs()) + get "/about": resp renderMain(renderAbout(), request, cfg, themePrefs()) @@ -91,9 +94,7 @@ 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 88c4e1e..8e2ac8f 100644 --- a/src/prefs_impl.nim +++ b/src/prefs_impl.nim @@ -50,11 +50,6 @@ 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" @@ -112,14 +107,6 @@ 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 deleted file mode 100644 index 1e5450d..0000000 --- a/src/routes/follow.nim +++ /dev/null @@ -1,42 +0,0 @@ -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 deleted file mode 100644 index dcdbb5e..0000000 --- a/src/routes/home.nim +++ /dev/null @@ -1,49 +0,0 @@ -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 98790a3..46a9679 100644 --- a/src/sass/profile/card.scss +++ b/src/sass/profile/card.scss @@ -1,139 +1,130 @@ -@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-and-follow { - @include breakable; - width: 100%; - display: flex; - flex-wrap: wrap; - justify-content: space-between; -} - -.profile-card-follow-button { - float: none; +.profile-card-tabs-name { + @include breakable; + max-width: 100%; } .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; - width: 100%; - margin-right: 4px; - margin-bottom: 6px; - - &:after { - content: ""; - display: block; - margin-top: 100%; - } - - img { - box-sizing: border-box; - position: absolute; + display: inline-block; + position: relative; width: 100%; - height: 100%; - border: 4px solid var(--darker_grey); - background: var(--bg_panel); - } + 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); + } } .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; - } - - .profile-card-tabs-name { - flex-shrink: 100; - } - - .profile-card-avatar { - width: 98px; - height: auto; - - img { - border-width: 2px; - width: unset; +@media(max-width: 700px) { + .profile-card-info { + display: flex; + } + + .profile-card-tabs-name { + flex-shrink: 100; + } + + .profile-card-avatar { + width: 80px; + height: 80px; + + img { + border-width: 2px; + width: unset; + } } - } } diff --git a/src/views/general.nim b/src/views/general.nim index fb49f56..87d30f2 100644 --- a/src/views/general.nim +++ b/src/views/general.nim @@ -52,11 +52,8 @@ 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=20") + link(rel="stylesheet", type="text/css", href="/css/style.css?v=19") 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 deleted file mode 100644 index 57acd2c..0000000 --- a/src/views/home.nim +++ /dev/null @@ -1,32 +0,0 @@ -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 b89bf43..2ec79f7 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; path: string): VNode = - buildHtml(tdiv(class="profile-card", "data-profile-id" = $user.id)): +proc renderUserCard*(user: User; prefs: Prefs): VNode = + buildHtml(tdiv(class="profile-card")): tdiv(class="profile-card-info"): let url = getPicUrl(user.getUserPic()) @@ -24,15 +24,9 @@ proc renderUserCard*(user: User; prefs: Prefs; path: string): VNode = a(class="profile-card-avatar", href=url, target="_blank"): genImg(user.getUserPic(size)) - 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-tabs-name"): + linkUser(user, class="profile-card-fullname") + linkUser(user, class="profile-card-username") tdiv(class="profile-card-extra"): if user.bio.len > 0: @@ -119,7 +113,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, path) + renderUserCard(profile.user, prefs) if profile.photoRail.len > 0: renderPhotoRail(profile) diff --git a/src/views/renderutils.nim b/src/views/renderutils.nim index 86a6806..f298fad 100644 --- a/src/views/renderutils.nim +++ b/src/views/renderutils.nim @@ -100,7 +100,3 @@ 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 1d40446..036a7b9 100644 --- a/src/views/rss.nimf +++ b/src/views/rss.nimf @@ -33,6 +33,10 @@ 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: @@ -50,12 +54,6 @@ 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 305129b..abeb6d3 100644 --- a/src/views/timeline.nim +++ b/src/views/timeline.nim @@ -55,16 +55,7 @@ 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 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 = +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 f890a55..13b4a24 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(): + a(href=getLink(tweet, false) & "/favoriters"): 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 deleted file mode 100644 index f7cfba0..0000000 --- a/twitter_oauth.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/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