diff --git a/.env b/.env index f1e729d..ca64195 100644 --- a/.env +++ b/.env @@ -142,6 +142,7 @@ LIBRESPEED_HOSTNAME=speedtest.${DOMAIN} MEDIAFLOW_PROXY_HOSTNAME=mediaflow-proxy.${DOMAIN} MEDIAFUSION_HOSTNAME=mediafusion.${DOMAIN} MINECRAFT_HOSTNAME=mc.${DOMAIN} +NITTER_HOSTNAME=nitter.${DOMAIN} NZBHYDRA2_HOSTNAME=nzbhydra2.${DOMAIN} OMG_TV_STREMIO_ADDON_HOSTNAME=omg-tv-addon.${DOMAIN} OVERSEERR_HOSTNAME=overseerr.${DOMAIN} diff --git a/apps/cloudflare-ddns/compose.yaml b/apps/cloudflare-ddns/compose.yaml index 230b3d4..ca108a3 100644 --- a/apps/cloudflare-ddns/compose.yaml +++ b/apps/cloudflare-ddns/compose.yaml @@ -41,6 +41,7 @@ services: ${MEDIAFLOW_PROXY_HOSTNAME}, ${MEDIAFUSION_HOSTNAME}, ${MINECRAFT_HOSTNAME}, + ${NITTER_HOSTNAME}, ${NZBHYDRA2_HOSTNAME}, ${OMG_TV_STREMIO_ADDON_HOSTNAME}, ${OVERSEERR_HOSTNAME}, diff --git a/apps/nitter/.env b/apps/nitter/.env new file mode 100644 index 0000000..a327a56 --- /dev/null +++ b/apps/nitter/.env @@ -0,0 +1,16 @@ +# The tag to use for the Nitter Docker image. +# If not on an arm64 architecture, you can use the latest tag. +NITTER_TAG=latest-arm64 + +# In order to run Nitter, we need to obtain the session secrets from Twitter. +# To do this, fill in the following values with your Twitter credentials: +# NOTE: Please remove the username and password from here after running the containers once successfully. +# You will find the sessions in the ./sessions/sessions.jsonl file to see if it worked, you can also check the logs of the nitter_sessions container. +TW_USERNAME= +TW_PASSWORD= +TW_OTP_SECRET= + +# Make sure to also edit the nitter.conf file to set the hostname to your own domain or IP address. +# And provide the hmacKey in the nitter.conf file, which is used for cryptographic signing of video URLs. +# You can generate a random key using the following command: +# openssl rand -base64 42 | tr -dc A-Za-z0-9 | cut -c -32 | tr -d '\n'; echo \ No newline at end of file diff --git a/apps/nitter/compose.yaml b/apps/nitter/compose.yaml new file mode 100644 index 0000000..3795da7 --- /dev/null +++ b/apps/nitter/compose.yaml @@ -0,0 +1,64 @@ +services: + nitter: + image: zedeus/nitter:${NITTER_TAG:-latest} + container_name: nitter + restart: unless-stopped + user: ${PUID}:${PGID} + expose: + - 8080 + labels: + - "traefik.enable=true" + - "traefik.http.routers.nitter.rule=Host(`${NITTER_HOSTNAME?}`)" + - "traefik.http.routers.nitter.entrypoints=websecure" + - "traefik.http.routers.nitter.tls.certresolver=letsencrypt" + - "traefik.http.services.nitter.loadbalancer.server.port=8080" + - "traefik.http.routers.nitter.middlewares=authelia@docker" + volumes: + - ./nitter.conf:/src/nitter.conf:Z,ro + - ./sessions/sessions.jsonl:/src/sessions.jsonl:Z,ro + depends_on: + nitter_redis: + condition: service_healthy + nitter_sessions: + condition: service_completed_successfully + healthcheck: + test: wget -nv --tries=1 --spider http://127.0.0.1:8080/Jack/status/20 || exit 1 + interval: 30s + timeout: 5s + start_period: 10s + retries: 2 + profiles: + - nitter + - all + + nitter_redis: + image: redis:6-alpine + container_name: nitter_redis + restart: unless-stopped + command: redis-server --save 60 1 --loglevel warning + volumes: + - ${DOCKER_DATA_DIR}/nitter:/data + healthcheck: + test: redis-cli ping + interval: 30s + timeout: 5s + retries: 2 + profiles: + - nitter + - all + + nitter_sessions: + build: + context: ./sessions + dockerfile: Dockerfile + container_name: nitter_sessions + restart: no + environment: + USERNAME: ${TW_USERNAME} + PASSWORD: ${TW_PASSWORD} + OTP_SECRET: ${TW_OTP_SECRET} + volumes: + - ./sessions:/app/sessions + profiles: + - nitter + - all \ No newline at end of file diff --git a/apps/nitter/nitter.conf b/apps/nitter/nitter.conf new file mode 100644 index 0000000..d44eb60 --- /dev/null +++ b/apps/nitter/nitter.conf @@ -0,0 +1,38 @@ +[Server] +hostname = "nitter.net" # for generating links, change this to your own domain/ip +title = "nitter" +address = "0.0.0.0" +port = 8080 +https = false # disable to enable cookies when not using https +httpMaxConnections = 100 +staticDir = "./public" + +[Cache] +listMinutes = 240 # how long to cache list info (not the tweets, so keep it high) +rssMinutes = 10 # how long to cache rss queries +redisHost = "nitter_redis" +redisPort = 6379 +redisPassword = "" +redisConnections = 20 # minimum open connections in pool +redisMaxConnections = 30 +# new connections are opened when none are available, but if the pool size +# goes above this, they're closed when released. don't worry about this unless +# you receive tons of requests per second + +[Config] +hmacKey = "80c6b3849c8f6e3013986eeecc1e70b06a1ef4e2168a37cb5b7b4385d241e13e" # random key for cryptographic signing of video urls +base64Media = false # use base64 encoding for proxied media urls +enableRSS = true # set this to false to disable RSS feeds +enableDebug = true # enable request logs and debug endpoints (/.sessions) +proxy = "http://warp:1080" # http/https url, SOCKS proxies are not supported +proxyAuth = "" + +# Change default preferences here, see src/prefs_impl.nim for a complete list +[Preferences] +theme = "Nitter" +replaceTwitter = "nitter.net" +replaceYouTube = "piped.video" +replaceReddit = "teddit.net" +proxyVideos = true +hlsPlayback = false +infiniteScroll = false \ No newline at end of file diff --git a/apps/nitter/sessions/Dockerfile b/apps/nitter/sessions/Dockerfile new file mode 100644 index 0000000..e261014 --- /dev/null +++ b/apps/nitter/sessions/Dockerfile @@ -0,0 +1,14 @@ +# Use a lightweight Python base image +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install dependencies +RUN pip install --no-cache-dir requests pyotp + +# Copy the script into the container +COPY get_session.py . + +# Default command (can be overridden) +ENTRYPOINT ["python3", "get_session.py"] diff --git a/apps/nitter/sessions/get_session.py b/apps/nitter/sessions/get_session.py new file mode 100644 index 0000000..1f986a5 --- /dev/null +++ b/apps/nitter/sessions/get_session.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +import requests +import json +import sys +import pyotp +import os + +# NOTE: pyotp and requests are dependencies +# > pip install pyotp requests + +TW_CONSUMER_KEY = '3nVuSoBZnx6U4vzUxf5w' +TW_CONSUMER_SECRET = 'Bcs59EFbbsdF6Sl9Ng71smgStWEGwXXKSjYvPVt7qys' + +def auth(username, password, otp_secret): + bearer_token_req = requests.post("https://api.twitter.com/oauth2/token", + auth=(TW_CONSUMER_KEY, TW_CONSUMER_SECRET), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data='grant_type=client_credentials' + ).json() + bearer_token = ' '.join(str(x) for x in bearer_token_req.values()) + + guest_token = requests.post( + "https://api.twitter.com/1.1/guest/activate.json", + headers={'Authorization': bearer_token} + ).json().get('guest_token') + + if not guest_token: + print("Failed to obtain guest token.") + sys.exit(1) + + twitter_header = { + 'Authorization': bearer_token, + "Content-Type": "application/json", + "User-Agent": "TwitterAndroid/10.21.0-release.0 (310210000-r-0) ONEPLUS+A3010/9 (OnePlus;ONEPLUS+A3010;OnePlus;OnePlus3;0;;1;2016)", + "X-Twitter-API-Version": '5', + "X-Twitter-Client": "TwitterAndroid", + "X-Twitter-Client-Version": "10.21.0-release.0", + "OS-Version": "28", + "System-User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9; ONEPLUS A3010 Build/PKQ1.181203.001)", + "X-Twitter-Active-User": "yes", + "X-Guest-Token": guest_token, + "X-Twitter-Client-DeviceID": "" + } + + session = requests.Session() + session.headers = twitter_header + + task1 = session.post( + 'https://api.twitter.com/1.1/onboarding/task.json', + params={ + 'flow_name': 'login', + 'api_version': '1', + 'known_device_token': '', + 'sim_country_code': 'us' + }, + json={ + "flow_token": None, + "input_flow_data": { + "country_code": None, + "flow_context": { + "referrer_context": { + "referral_details": "utm_source=google-play&utm_medium=organic", + "referrer_url": "" + }, + "start_location": { + "location": "deeplink" + } + }, + "requested_variant": None, + "target_user_id": 0 + } + } + ) + + session.headers['att'] = task1.headers.get('att') + + task2 = session.post( + 'https://api.twitter.com/1.1/onboarding/task.json', + json={ + "flow_token": task1.json().get('flow_token'), + "subtask_inputs": [{ + "enter_text": { + "suggestion_id": None, + "text": username, + "link": "next_link" + }, + "subtask_id": "LoginEnterUserIdentifier" + }] + } + ) + + task3 = session.post( + 'https://api.twitter.com/1.1/onboarding/task.json', + json={ + "flow_token": task2.json().get('flow_token'), + "subtask_inputs": [{ + "enter_password": { + "password": password, + "link": "next_link" + }, + "subtask_id": "LoginEnterPassword" + }], + } + ) + + for t3_subtask in task3.json().get('subtasks', []): + if "open_account" in t3_subtask: + return t3_subtask["open_account"] + elif "enter_text" in t3_subtask: + response_text = t3_subtask["enter_text"]["hint_text"] + totp = pyotp.TOTP(otp_secret) + generated_code = totp.now() + task4resp = session.post( + "https://api.twitter.com/1.1/onboarding/task.json", + json={ + "flow_token": task3.json().get("flow_token"), + "subtask_inputs": [ + { + "enter_text": { + "suggestion_id": None, + "text": generated_code, + "link": "next_link", + }, + "subtask_id": "LoginTwoFactorAuthChallenge", + } + ], + } + ) + task4 = task4resp.json() + for t4_subtask in task4.get("subtasks", []): + if "open_account" in t4_subtask: + return t4_subtask["open_account"] + + return None + +if __name__ == "__main__": + username = os.getenv("USERNAME") + password = os.getenv("PASSWORD") + otp_secret = os.getenv("OTP_SECRET") + path = os.getenv("SESSION_PATH", "/app/sessions/sessions.jsonl") + os.makedirs(os.path.dirname(path), exist_ok=True) + + if not username or not password: + # parse session file + try: + with open(path, "r") as f: + sessions = [json.loads(line) for line in f if line.strip()] + # if no sessions found, exit + if not sessions: + print("No sessions found. Please provide username and password.") + sys.exit(1) + # exit with 0 status code + print("No username or password provided. Using existing sessions.") + sys.exit(0) + except FileNotFoundError: + print("Session file not found. Please provide username and password.") + sys.exit(1) + + + result = auth(username, password, otp_secret) + if result is None: + print("Authentication failed.") + sys.exit(1) + + session_entry = { + "oauth_token": result.get("oauth_token"), + "oauth_token_secret": result.get("oauth_token_secret") + } + + try: + with open(path, "a") as f: + f.write(json.dumps(session_entry) + "\n") + print("Authentication successful. Session appended to", path) + except Exception as e: + print(f"Failed to write session information: {e}") + sys.exit(1) \ No newline at end of file diff --git a/apps/nitter/sessions/sessions.jsonl b/apps/nitter/sessions/sessions.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/compose.yaml b/compose.yaml index b8adde7..b52a840 100644 --- a/compose.yaml +++ b/compose.yaml @@ -31,6 +31,7 @@ include: - apps/mediaflow-proxy/compose.yaml - apps/mediafusion/compose.yaml - apps/minecraft/compose.yaml + - apps/nitter/compose.yaml - apps/nzbhydra2/compose.yaml - apps/omg-tv-addon/compose.yaml - apps/overseerr/compose.yaml