diff --git a/README.darkness.md b/README.darkness.md index af7b217..e67314e 100644 --- a/README.darkness.md +++ b/README.darkness.md @@ -50,6 +50,9 @@ Changes: - `safetwitch.darkness.services` - `api.safetwitch.darkness.services` - onion equivalents +- Compose runtime defaults now target local port-mapped endpoints (`localhost:8280` / `localhost:7100`). +- Public hostnames are configured explicitly through Traefik host override variables instead of being baked into runtime defaults. +- `docker/.env` is the Compose-facing domain configuration so running `docker compose` from `docker/` resolves the intended clearnet hosts. ## Playback and UX Reliability Changes diff --git a/README.md b/README.md index 6f58966..7d92bb6 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ Instead, please donate your money to one of these charities which mean a lot to All documentation can be found on the [wiki](https://codeberg.org/SafeTwitch/safetwitch/wiki) +## Deployment Notes + +For the `darkness.services` instance setup and hardening details, see [README.darkness.md](README.darkness.md). + ## Translate diff --git a/docker/.env b/docker/.env new file mode 100644 index 0000000..b122d93 --- /dev/null +++ b/docker/.env @@ -0,0 +1,9 @@ +SAFETWITCH_BACKEND_DOMAIN=api.safetwitch.darkness.services +SAFETWITCH_INSTANCE_DOMAIN=safetwitch.darkness.services +SAFETWITCH_HTTPS=true +SAFETWITCH_BACKEND_URL=https://api.safetwitch.darkness.services +SAFETWITCH_TRAEFIK_FRONTEND_HOST=safetwitch.darkness.services +SAFETWITCH_TRAEFIK_BACKEND_HOST=api.safetwitch.darkness.services +SAFETWITCH_DEFAULT_LOCALE=en +SAFETWITCH_FALLBACK_LOCALE=en +SAFETWITCH_DEFAULT_THEME=dark diff --git a/docker/Dockerfile b/docker/Dockerfile index a692500..9b5c057 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,7 +22,7 @@ ENV SAFETWITCH_DEFAULT_THEME SAFETWITCH_DEFAULT_THEME_PLACEHOLDER COPY ./ . RUN ls -# install node modules and build assets +# install node modules and build assets (skip vue-tsc in container build) RUN npm i && npm run build-only # nginx state for serving content diff --git a/docker/docker-compose.build.yml b/docker/docker-compose.build.yml index db37d33..de126ba 100644 --- a/docker/docker-compose.build.yml +++ b/docker/docker-compose.build.yml @@ -13,7 +13,7 @@ services: environment: - SAFETWITCH_BACKEND_DOMAIN=localhost:7100 - SAFETWITCH_INSTANCE_DOMAIN=localhost:8280 - - SAFETWITCH_HTTPS=true + - SAFETWITCH_HTTPS=false - SAFETWITCH_DEFAULT_THEME=dark - SAFETWITCH_DEFAULT_LOCALE=en-US - SAFETWITCH_FALLBACK_LOCALE=en-US @@ -32,4 +32,4 @@ services: - "127.0.0.1:7100:7000" environment: - PORT=7000 - - URL=changeme \ No newline at end of file + - URL=http://localhost:7100 \ No newline at end of file diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index e20018b..68e3490 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -12,15 +12,15 @@ services: ports: - "127.0.0.1:${SAFETWITCH_FRONTEND_PORT:-8280}:8280" environment: - - SAFETWITCH_BACKEND_DOMAIN=${SAFETWITCH_BACKEND_DOMAIN:-api.safetwitch.darkness.services} - - SAFETWITCH_INSTANCE_DOMAIN=${SAFETWITCH_INSTANCE_DOMAIN:-safetwitch.darkness.services} - - SAFETWITCH_HTTPS=${SAFETWITCH_HTTPS:-true} + - SAFETWITCH_BACKEND_DOMAIN=${SAFETWITCH_BACKEND_DOMAIN:-localhost:7100} + - SAFETWITCH_INSTANCE_DOMAIN=${SAFETWITCH_INSTANCE_DOMAIN:-localhost:8280} + - SAFETWITCH_HTTPS=${SAFETWITCH_HTTPS:-false} - SAFETWITCH_DEFAULT_LOCALE=${SAFETWITCH_DEFAULT_LOCALE:-en} - SAFETWITCH_FALLBACK_LOCALE=${SAFETWITCH_FALLBACK_LOCALE:-en} - SAFETWITCH_DEFAULT_THEME=${SAFETWITCH_DEFAULT_THEME:-dark} labels: - "traefik.enable=true" - - "traefik.http.routers.safetwitch.rule=Host(`safetwitch.darkness.services`)" + - "traefik.http.routers.safetwitch.rule=Host(`${SAFETWITCH_TRAEFIK_FRONTEND_HOST:-safetwitch.darkness.services}`)" - "traefik.http.routers.safetwitch.entrypoints=https" - "traefik.http.routers.safetwitch.tls.certresolver=myresolver" - "traefik.http.services.safetwitch.loadbalancer.server.port=8280" @@ -54,15 +54,19 @@ services: cap_drop: - ALL restart: always - image: codeberg.org/safetwitch/safetwitch-backend:latest + image: safetwitch-backend-patched:latest ports: - "127.0.0.1:${SAFETWITCH_BACKEND_PORT:-7100}:7000" environment: - PORT=7000 - - URL=${SAFETWITCH_BACKEND_URL:-https://api.safetwitch.darkness.services} + - URL=${SAFETWITCH_BACKEND_URL:-http://localhost:7100} + deploy: + resources: + limits: + cpus: "1.0" labels: - "traefik.enable=true" - - "traefik.http.routers.apisafetwitch.rule=Host(`api.safetwitch.darkness.services`)" + - "traefik.http.routers.apisafetwitch.rule=Host(`${SAFETWITCH_TRAEFIK_BACKEND_HOST:-api.safetwitch.darkness.services}`)" - "traefik.http.routers.apisafetwitch.entrypoints=https" - "traefik.http.routers.apisafetwitch.tls.certresolver=myresolver" - "traefik.http.services.apisafetwitch.loadbalancer.server.port=7000" @@ -76,12 +80,16 @@ services: cap_drop: - ALL restart: always - image: codeberg.org/safetwitch/safetwitch-backend:latest + image: safetwitch-backend-patched:latest ports: - "127.0.0.1:${SAFETWITCH_BACKEND_TOR_PORT:-7101}:7000" environment: - PORT=7000 - URL=http://api.safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion + deploy: + resources: + limits: + cpus: "0.5" labels: - "traefik.enable=true" - "traefik.http.routers.apisafetwitch-tor.rule=Host(`api.safetwitch.darknessrdor43qkl2ngwitj72zdavfz2cead4t5ed72bybgauww5lyd.onion`)" @@ -108,7 +116,7 @@ services: - BACKEND_BASE_URL=http://safetwitch-backend:7000 labels: - "traefik.enable=true" - - "traefik.http.routers.apisafetwitch-search.rule=Host(`api.safetwitch.darkness.services`) && PathPrefix(`/api/search`)" + - "traefik.http.routers.apisafetwitch-search.rule=Host(`${SAFETWITCH_TRAEFIK_BACKEND_HOST:-api.safetwitch.darkness.services}`) && PathPrefix(`/api/search`)" - "traefik.http.routers.apisafetwitch-search.entrypoints=https" - "traefik.http.routers.apisafetwitch-search.tls.certresolver=myresolver" - "traefik.http.routers.apisafetwitch-search.priority=100" diff --git a/docker/nginx.conf b/docker/nginx.conf index a284f10..6659466 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -23,6 +23,14 @@ http { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Range $http_range; + proxy_set_header If-Range $http_if_range; + proxy_buffering off; + proxy_request_buffering off; + proxy_max_temp_file_size 0; + proxy_read_timeout 1h; + proxy_send_timeout 1h; + add_header Accept-Ranges bytes always; } location / { diff --git a/docker/substitute_environment_variables.sh b/docker/substitute_environment_variables.sh index e545fb8..9e6ec8f 100755 --- a/docker/substitute_environment_variables.sh +++ b/docker/substitute_environment_variables.sh @@ -1,39 +1,73 @@ #!/bin/sh -ROOT_DIR=/app -# Save old variables +set -eu -# Get old vars -if [ -f .env.old ]; then - export $(echo $(cat .env.old | sed 's/#.*//g'| xargs) | envsubst) +ROOT_DIR=/app + +# Get previous placeholder values if available. +if [ -f ./.env.old ]; then + # shellcheck disable=SC1091 + . ./.env.old else - export SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER=SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER - export SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER=SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER - export SAFETWITCH_HTTPS_PLACEHOLDER=SAFETWITCH_HTTPS_PLACEHOLDER - export SAFETWITCH_DEFAULT_THEME_PLACEHOLDER=SAFETWITCH_DEFAULT_THEME_PLACEHOLDER - export SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER=SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER - export SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER=SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER + SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER=SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER + SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER=SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER + SAFETWITCH_HTTPS_PLACEHOLDER=SAFETWITCH_HTTPS_PLACEHOLDER + SAFETWITCH_DEFAULT_THEME_PLACEHOLDER=SAFETWITCH_DEFAULT_THEME_PLACEHOLDER + SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER=SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER + SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER=SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER fi +SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER=${SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER:-SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER} +SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER=${SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER:-SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER} +SAFETWITCH_HTTPS_PLACEHOLDER=${SAFETWITCH_HTTPS_PLACEHOLDER:-SAFETWITCH_HTTPS_PLACEHOLDER} +SAFETWITCH_DEFAULT_THEME_PLACEHOLDER=${SAFETWITCH_DEFAULT_THEME_PLACEHOLDER:-SAFETWITCH_DEFAULT_THEME_PLACEHOLDER} +SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER=${SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER:-SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER} +SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER=${SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER:-SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER} + +SAFETWITCH_BACKEND_DOMAIN=${SAFETWITCH_BACKEND_DOMAIN:-} +SAFETWITCH_INSTANCE_DOMAIN=${SAFETWITCH_INSTANCE_DOMAIN:-} +SAFETWITCH_HTTPS=${SAFETWITCH_HTTPS:-} +SAFETWITCH_DEFAULT_THEME=${SAFETWITCH_DEFAULT_THEME:-} +SAFETWITCH_DEFAULT_LOCALE=${SAFETWITCH_DEFAULT_LOCALE:-} +SAFETWITCH_FALLBACK_LOCALE=${SAFETWITCH_FALLBACK_LOCALE:-} + +escape_sed_replacement() { + printf '%s' "$1" | sed 's/[&|]/\\&/g' +} + +replace_placeholder() { + placeholder="$1" + value="$2" + file="$3" + + # Avoid invalid sed expressions like s||...|g when placeholder is empty. + [ -n "$placeholder" ] || return 0 + + escaped_value=$(escape_sed_replacement "$value") + sed -i "s|$placeholder|$escaped_value|g" "$file" +} + # Replace env vars in files served by NGINX -for file in $ROOT_DIR/assets/*.js $ROOT_DIR/index.html; +for file in "$ROOT_DIR"/assets/*.js "$ROOT_DIR"/index.html; do - sed -i 's|'${SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER}'|'${SAFETWITCH_BACKEND_DOMAIN}'|g' $file - sed -i 's|'${SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER}'|'${SAFETWITCH_INSTANCE_DOMAIN}'|g' $file - sed -i 's|'${SAFETWITCH_HTTPS_PLACEHOLDER}'|'${SAFETWITCH_HTTPS}'|g' $file - sed -i 's|'${SAFETWITCH_DEFAULT_THEME_PLACEHOLDER}'|'${SAFETWITCH_DEFAULT_THEME}'|g' $file - sed -i 's|'${SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER}'|'${SAFETWITCH_DEFAULT_LOCALE}'|g' $file - sed -i 's|'${SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER}'|'${SAFETWITCH_FALLBACK_LOCALE}'|g' $file - # Your other variables here... + [ -e "$file" ] || continue + + replace_placeholder "$SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER" "$SAFETWITCH_BACKEND_DOMAIN" "$file" + replace_placeholder "$SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER" "$SAFETWITCH_INSTANCE_DOMAIN" "$file" + replace_placeholder "$SAFETWITCH_HTTPS_PLACEHOLDER" "$SAFETWITCH_HTTPS" "$file" + replace_placeholder "$SAFETWITCH_DEFAULT_THEME_PLACEHOLDER" "$SAFETWITCH_DEFAULT_THEME" "$file" + replace_placeholder "$SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER" "$SAFETWITCH_DEFAULT_LOCALE" "$file" + replace_placeholder "$SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER" "$SAFETWITCH_FALLBACK_LOCALE" "$file" done -# Save old variables -echo -e "SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER=${SAFETWITCH_BACKEND_DOMAIN}" > .env.old -echo -e "SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER=${SAFETWITCH_INSTANCE_DOMAIN}" >> .env.old -echo -e "SAFETWITCH_HTTPS_PLACEHOLDER=${SAFETWITCH_HTTPS}" >> .env.old -echo -e "SAFETWITCH_DEFAULT_THEME_PLACEHOLDER=${SAFETWITCH_DEFAULT_THEME}" >> .env.old -echo -e "SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER=${SAFETWITCH_DEFAULT_LOCALE}" >> .env.old -echo -e "SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER=${SAFETWITCH_FALLBACK_LOCALE}" >> .env.old +# Save current variables for next container restart. +cat > ./.env.old <(value: T[] | null | undefined): T[] { + return Array.isArray(value) ? value : [] +} + +const fallbackDiscoverCategorySeeds = [ + { name: 'Just Chatting', tags: ['IRL'] }, + { name: 'League of Legends', tags: ['MOBA'] }, + { name: 'VALORANT', tags: ['FPS'] }, + { name: 'Minecraft', tags: ['Sandbox'] }, + { name: 'Fortnite', tags: ['Battle Royale'] }, + { name: 'Counter-Strike', tags: ['FPS'] }, + { name: 'Apex Legends', tags: ['Battle Royale'] }, + { name: 'Dota 2', tags: ['MOBA'] }, + { name: 'World of Warcraft', tags: ['MMO'] }, + { name: 'Grand Theft Auto V', tags: ['Open World'] }, + { name: 'Diablo IV', tags: ['ARPG'] }, + { name: 'Marvel Rivals', tags: ['Hero Shooter'] } +] as const + +function getProxyImageUrl(sourceUrl: string) { + return `${rootBackendUrl}proxy/img/${btoa(sourceUrl)}` +} + +export function getFallbackDiscoverCategories(query = ''): CategoryPreview[] { + const loweredQuery = query.trim().toLowerCase() + + return fallbackDiscoverCategorySeeds + .filter(({ name, tags }) => { + if (!loweredQuery) { + return true + } + + const loweredName = name.toLowerCase() + return loweredName.includes(loweredQuery) || tags.some((tag) => tag.toLowerCase().includes(loweredQuery)) + }) + .map(({ name, tags }) => ({ + name, + displayName: name, + viewers: 0, + tags, + image: getProxyImageUrl( + `https://static-cdn.jtvnw.net/ttv-boxart/${encodeURIComponent(name)}-285x380.jpg` + ) + })) +} + /** * Converts a twitch timestamp (0h0m0s) to seconds * @param query 0h0m0s diff --git a/src/views/HomepageView.vue b/src/views/HomepageView.vue index dd1f482..68d0628 100644 --- a/src/views/HomepageView.vue +++ b/src/views/HomepageView.vue @@ -6,7 +6,7 @@ import ErrorMessage from '@/components/ErrorMessage.vue' import LoadingScreen from '@/components/LoadingScreen.vue' import CategoryPreview from '@/components/CategoryPreview.vue' -import { getEndpoint, followersStreaming } from '@/mixins' +import { ensureArray, getEndpoint, getFallbackDiscoverCategories, followersStreaming } from '@/mixins' import type { CategoryPreview as CategoryPreviewInterface } from '@/types' import { getFollows } from '@/settingsManager' import VueTitle from '@/components/VueTitle.vue' @@ -67,13 +67,9 @@ export default { window.onscroll = this.getNextCategory // get discover page - await getEndpoint('api/discover') - .catch(() => { - this.status = 'error' - }) - .then((data: CategoryPreviewInterface[]) => { - this.data = data - }) + const discoverData = await getEndpoint('api/discover').catch(() => null) + const categories = ensureArray(discoverData as CategoryPreviewInterface[] | null | undefined) + this.data = categories.length > 0 ? categories : getFallbackDiscoverCategories() this.following = await getFollows() this.followingStreaming = [] diff --git a/src/views/SearchPageView.vue b/src/views/SearchPageView.vue index 82bcee7..391ca3b 100644 --- a/src/views/SearchPageView.vue +++ b/src/views/SearchPageView.vue @@ -7,7 +7,7 @@ import LoadingScreen from '@/components/LoadingScreen.vue' import StreamPreviewVue from '@/components/StreamPreview.vue' import ChannelPreview from '@/components/ChannelPreview.vue' -import { getEndpoint } from '@/mixins' +import { ensureArray, getEndpoint, getFallbackDiscoverCategories } from '@/mixins' import type { SearchResult, StreamerData } from '@/types' export default { @@ -59,7 +59,7 @@ export default { await getEndpoint('api/discover') .then((categories) => { const loweredQuery = query.toLowerCase() - fallback.categories = categories + fallback.categories = ensureArray(categories) .filter((category: { name: string; displayName?: string }) => { const name = String(category.name || '').toLowerCase() const displayName = String(category.displayName || '').toLowerCase() @@ -71,6 +71,10 @@ export default { // Ignore fallback category lookup errors. }) + if (fallback.categories.length === 0) { + fallback.categories = getFallbackDiscoverCategories(query).slice(0, 24) + } + return fallback }, async runSearch() { diff --git a/tools/refresh-hashes.py b/tools/refresh-hashes.py new file mode 100755 index 0000000..9404ce3 --- /dev/null +++ b/tools/refresh-hashes.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +""" +Safetwitch Automated Hash Refresher & Health Watcher +- Verifies Safetwitch backend API health. +- If endpoints fail due to expired/rotated Twitch GraphQL hashes, uses browserless/playwright + to crawl Twitch, extract live sha256 persistedQuery hashes, patches the backend binary/image, + and reloads the container stack cleanly. +""" + +import os +import sys +import json +import time +import subprocess +import urllib.request +import urllib.error + +SAFETWITCH_DIR = "/srv/safetwitch" +BACKEND_HEALTH_URL = os.environ.get("SAFETWITCH_HEALTH_URL", "http://127.0.0.1:7100/api/discover") +BROWSERLESS_CONTAINER = os.environ.get("BROWSERLESS_CONTAINER", "browserless") + +# Key GraphQL operations Safetwitch relies on +OPERATION_NAMES = [ + "BrowsePage_AllDirectories", + "DirectoryPage_Game", + "Directory_DirectoryBanner", + "UseLive", + "VideoPreviewOverlay", + "StreamTagsTrackingChannel", + "ChannelShell", + "VideoPlayerStreamMetadata" +] + +def check_backend_health() -> bool: + try: + req = urllib.request.Request( + BACKEND_HEALTH_URL, + headers={"User-Agent": "Safetwitch-Watcher/1.0", "Accept": "application/json"} + ) + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status == 200: + data = json.loads(resp.read().decode("utf-8")) + if data.get("status") == "ok" and len(data.get("data", [])) > 0: + return True + except Exception as e: + print(f"[!] Health check failed: {e}") + return False + +def extract_live_hashes() -> dict: + print("[*] Launching browser extraction via browserless container...") + js_script = r''' +const { chromium } = require("/usr/src/app/node_modules/playwright-core"); + +(async () => { + const browser = await chromium.launch({ + executablePath: "/usr/local/bin/playwright-browsers/chromium-1217/chrome-linux64/chrome", + args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", "--disable-gpu"] + }); + const page = await browser.newPage(); + const operations = {}; + + page.on("request", req => { + if (req.url().includes("gql")) { + try { + const post = req.postData(); + if (post) { + const data = JSON.parse(post); + const items = Array.isArray(data) ? data : [data]; + for (const item of items) { + if (item.operationName && item.extensions?.persistedQuery?.sha256Hash) { + operations[item.operationName] = item.extensions.persistedQuery.sha256Hash; + } + } + } + } catch (e) {} + } + }); + + const urls = [ + "https://www.twitch.tv/directory", + "https://www.twitch.tv/directory/category/just-chatting", + "https://www.twitch.tv/gaules" + ]; + + for (const u of urls) { + try { + await page.goto(u, { waitUntil: "domcontentloaded", timeout: 25000 }); + await page.waitForTimeout(4000); + } catch (e) {} + } + + console.log("EXTRACTED_HASHES=" + JSON.stringify(operations)); + await browser.close(); +})(); +''' + res = subprocess.run( + ["docker", "exec", "-i", BROWSERLESS_CONTAINER, "node", "-e", js_script], + capture_output=True, + text=True, + timeout=120 + ) + + for line in res.stdout.splitlines(): + if line.startswith("EXTRACTED_HASHES="): + json_str = line.split("=", 1)[1] + return json.loads(json_str) + + raise RuntimeError(f"Failed to extract hashes from browserless: {res.stderr or res.stdout}") + +def patch_backend_binary(extracted_hashes: dict): + print("[*] Checking and patching backend image...") + # Extract current binary from safetwitch-backend-patched:latest + temp_container = "temp-patch-backend-" + str(int(time.time())) + subprocess.run(["docker", "create", "--name", temp_container, "safetwitch-backend-patched:latest"], check=True) + tmp_bin = f"/tmp/server-{int(time.time())}" + subprocess.run(["docker", "cp", f"{temp_container}:/server", tmp_bin], check=True) + subprocess.run(["docker", "rm", temp_container], check=True) + + with open(tmp_bin, "rb") as f: + data = bytearray(f.read()) + + # Map operations to their old / known hashes in binary + # 7: BrowsePage_AllDirectories + # 8: DirectoryPage_Game + # 9: Directory_DirectoryBanner + # 17: UseLive + # 14: VideoPreviewOverlay + # 13: StreamTagsTrackingChannel + + op_map = { + "BrowsePage_AllDirectories": b"2f67f71ba89f3c0ed26a141ec00da1defecb2303595f5cda4298169549783d9e", + "DirectoryPage_Game": b"86bcceb4e8b1a51256ff8eed8bd8aae4acacf80d737efe904f84f3aeadf8cafd", + "Directory_DirectoryBanner": b"822ecf40c2a77568d2b223fd5bc4dfdc9c863f081dd1ca7611803a5330e88277", + "UseLive": b"639d5f11bfb8bf3053b424d9ef650d04c4ebb7d94711d644afb08fe9a0fad5d9", + "VideoPreviewOverlay": b"9515480dee68a77e667cb19de634739d33f243572b007e98e67184b1a5d8369f", + "StreamTagsTrackingChannel": b"6aa3851aaaf88c320d514eb173563d430b28ed70fdaaf7eeef6ed4b812f48608", + } + + modified = False + for op_name, new_hash in extracted_hashes.items(): + if op_name in op_map and new_hash: + new_hash_bytes = new_hash.encode("ascii") + curr_target = op_map[op_name] + if curr_target in data and curr_target != new_hash_bytes: + print(f"[*] Updating {op_name}: {curr_target.decode()} -> {new_hash}") + data = data.replace(curr_target, new_hash_bytes) + modified = True + + if not modified: + print("[+] Backend binary is already up to date with extracted hashes.") + os.remove(tmp_bin) + return False + + with open(tmp_bin, "wb") as f: + f.write(data) + + # Build updated docker image + dockerfile_content = f"""FROM alpine:3.24 +COPY {os.path.basename(tmp_bin)} /server +RUN chmod +x /server +ENTRYPOINT ["/server"] +""" + tmp_df = f"/tmp/Dockerfile-{int(time.time())}" + with open(tmp_df, "w") as f: + f.write(dockerfile_content) + + print("[*] Rebuilding safetwitch-backend-patched:latest image...") + subprocess.run( + ["docker", "build", "-t", "safetwitch-backend-patched:latest", "-f", tmp_df, "/tmp"], + check=True + ) + + # Cleanup temp files + os.remove(tmp_bin) + os.remove(tmp_df) + + # Recreate backend containers in compose + print("[*] Restarting backend containers...") + subprocess.run( + ["docker", "compose", "up", "-d", "--force-recreate", "safetwitch-backend", "safetwitch-backend-tor"], + cwd=os.path.join(SAFETWITCH_DIR, "docker"), + check=True + ) + print("[+] Successfully refreshed hashes and reloaded backend.") + return True + +def run(force=False): + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Checking Safetwitch status...") + if not force and check_backend_health(): + print("[+] Safetwitch backend is healthy and responding.") + return 0 + + print("[!] Safetwitch backend unhealthy or refresh forced. Starting automatic refresh...") + try: + hashes = extract_live_hashes() + print(f"[+] Found {len(hashes)} live operations.") + patch_backend_binary(hashes) + time.sleep(3) + if check_backend_health(): + print("[+] Verification passed: Safetwitch is healthy!") + return 0 + else: + print("[!] Verification failed after refresh.") + return 1 + except Exception as e: + print(f"[!] Refresh error: {e}") + return 1 + +if __name__ == "__main__": + force_run = "--force" in sys.argv + sys.exit(run(force=force_run))