feat: add automated hash refresh tooling and hardening fixes
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
<a href="https://translate.codeberg.org/engage/safetwitch/">
|
||||
|
||||
@@ -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
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
- URL=http://localhost:7100
|
||||
@@ -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"
|
||||
|
||||
@@ -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 / {
|
||||
|
||||
@@ -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 <<EOF
|
||||
SAFETWITCH_BACKEND_DOMAIN_PLACEHOLDER=${SAFETWITCH_BACKEND_DOMAIN}
|
||||
SAFETWITCH_INSTANCE_DOMAIN_PLACEHOLDER=${SAFETWITCH_INSTANCE_DOMAIN}
|
||||
SAFETWITCH_HTTPS_PLACEHOLDER=${SAFETWITCH_HTTPS}
|
||||
SAFETWITCH_DEFAULT_THEME_PLACEHOLDER=${SAFETWITCH_DEFAULT_THEME}
|
||||
SAFETWITCH_DEFAULT_LOCALE_PLACEHOLDER=${SAFETWITCH_DEFAULT_LOCALE}
|
||||
SAFETWITCH_FALLBACK_LOCALE_PLACEHOLDER=${SAFETWITCH_FALLBACK_LOCALE}
|
||||
EOF
|
||||
|
||||
# Starting NGINX
|
||||
nginx -g 'daemon off;'
|
||||
exec nginx -g 'daemon off;'
|
||||
+47
-1
@@ -1,4 +1,4 @@
|
||||
import type { FollowingStreamer } from '@/types'
|
||||
import type { CategoryPreview, FollowingStreamer } from '@/types'
|
||||
|
||||
const language = localStorage.getItem('language') || 'en-us'
|
||||
const https = import.meta.env.SAFETWITCH_HTTPS.slice() === 'true'
|
||||
@@ -74,6 +74,52 @@ export async function postEndpoint(endpoint: string, data: any) {
|
||||
return finalData
|
||||
}
|
||||
|
||||
export function ensureArray<T>(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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Executable
+211
@@ -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))
|
||||
Reference in New Issue
Block a user