Skip to content

Commit 4924c38

Browse files
feat: add migration script to migrate old tokens to new system
1 parent 5f8526c commit 4924c38

File tree

3 files changed

+252
-3
lines changed

3 files changed

+252
-3
lines changed

app/core/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import os
23
from contextlib import asynccontextmanager
34
from pathlib import Path
@@ -10,6 +11,7 @@
1011

1112
from app.api.main import api_router
1213
from app.services.catalog_updater import BackgroundCatalogUpdater
14+
from app.startup.migration import migrate_tokens
1315

1416
from .config import settings
1517
from .version import __version__
@@ -36,6 +38,7 @@ async def lifespan(app: FastAPI):
3638
Manage application lifespan events (startup/shutdown).
3739
"""
3840
global catalog_updater
41+
asyncio.create_task(migrate_tokens())
3942

4043
# Startup
4144
if settings.AUTO_UPDATE_CATALOGS:

app/startup/migration.py

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import base64
2+
import hashlib
3+
import json
4+
import traceback
5+
6+
import httpx
7+
import redis.asyncio as redis
8+
from cryptography.fernet import Fernet
9+
from cryptography.hazmat.primitives import hashes
10+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
11+
from loguru import logger
12+
13+
from app.core.config import settings
14+
15+
16+
def decrypt_data(enc_json: str):
17+
key_bytes = hashlib.sha256(settings.TOKEN_SALT.encode()).digest()
18+
fernet_key = base64.urlsafe_b64encode(key_bytes)
19+
cipher = Fernet(fernet_key)
20+
if not isinstance(enc_json, str):
21+
return {}
22+
try:
23+
decrypted = cipher.decrypt(enc_json.encode()).decode()
24+
except Exception as exc:
25+
logger.warning(f"Failed to decrypt data: {exc}")
26+
raise exc
27+
return json.loads(decrypted)
28+
29+
30+
async def get_auth_key(username: str, password: str):
31+
url = "https://api.strem.io/api/login"
32+
payload = {
33+
"email": username,
34+
"password": password,
35+
"type": "Login",
36+
"facebook": False,
37+
}
38+
async with httpx.AsyncClient() as client:
39+
result = await client.post(url, json=payload)
40+
result.raise_for_status()
41+
data = result.json()
42+
auth_key = data.get("result", {}).get("authKey", "")
43+
return auth_key
44+
45+
46+
async def get_user_info(auth_key):
47+
url = "https://api.strem.io/api/getUser"
48+
payload = {
49+
"type": "GetUser",
50+
"authKey": auth_key,
51+
}
52+
async with httpx.AsyncClient() as client:
53+
response = await client.post(url, json=payload)
54+
response.raise_for_status()
55+
data = response.json()
56+
result = data.get("result", {})
57+
email = result.get("email")
58+
user_id = result.get("_id")
59+
return email, user_id
60+
61+
62+
async def get_addons(auth_key: str):
63+
url = "https://api.strem.io/api/addonCollectionGet"
64+
payload = {
65+
"type": "AddonCollectionGet",
66+
"authKey": auth_key,
67+
"update": True,
68+
}
69+
async with httpx.AsyncClient() as client:
70+
result = await client.post(url, json=payload)
71+
result.raise_for_status()
72+
data = result.json()
73+
error_payload = data.get("error")
74+
if not error_payload and (data.get("code") and data.get("message")):
75+
error_payload = data
76+
77+
if error_payload:
78+
message = "Invalid Stremio auth key."
79+
if isinstance(error_payload, dict):
80+
message = error_payload.get("message") or message
81+
elif isinstance(error_payload, str):
82+
message = error_payload or message
83+
logger.warning(f"Addon collection request failed: {error_payload}")
84+
raise ValueError(f"Stremio: {message}")
85+
addons = data.get("result", {}).get("addons", [])
86+
logger.info(f"Found {len(addons)} addons")
87+
return addons
88+
89+
90+
async def update_addon_url(auth_key: str, user_id: str):
91+
addons = await get_addons(auth_key)
92+
hostname = settings.HOST_NAME if settings.HOST_NAME.startswith("https") else f"https://{settings.HOST_NAME}"
93+
for addon in addons:
94+
if addon.get("manifest", {}).get("id") == settings.ADDON_ID:
95+
addon["transportUrl"] = f"{hostname}/{user_id}/manifest.json"
96+
97+
url = "https://api.strem.io/api/addonCollectionSet"
98+
payload = {
99+
"type": "AddonCollectionSet",
100+
"authKey": auth_key,
101+
"addons": addons,
102+
}
103+
104+
async with httpx.AsyncClient() as client:
105+
result = await client.post(url, json=payload)
106+
result.raise_for_status()
107+
logger.info("Updated addon url")
108+
return result.json().get("result", {}).get("success", False)
109+
110+
111+
async def decode_old_payloads(encrypted_raw: str):
112+
key_bytes = hashlib.sha256(settings.TOKEN_SALT.encode()).digest()
113+
fernet_key = base64.urlsafe_b64encode(key_bytes)
114+
cipher = Fernet(fernet_key)
115+
decrypted_json = cipher.decrypt(encrypted_raw.encode()).decode("utf-8")
116+
payload = json.loads(decrypted_json)
117+
return payload
118+
119+
120+
async def encrypt_auth_key(auth_key):
121+
salt = b"x7FDf9kypzQ1LmR32b8hWv49sKq2Pd8T"
122+
kdf = PBKDF2HMAC(
123+
algorithm=hashes.SHA256(),
124+
length=32,
125+
salt=salt,
126+
iterations=200_000,
127+
)
128+
129+
key = base64.urlsafe_b64encode(kdf.derive(settings.TOKEN_SALT.encode("utf-8")))
130+
client = Fernet(key)
131+
return client.encrypt(auth_key.encode("utf-8")).decode("utf-8")
132+
133+
134+
def prepare_default_payload(email, user_id):
135+
return {
136+
"email": email,
137+
"user_id": user_id,
138+
"settings": {
139+
"catalogs": [
140+
{"id": "watchly.rec", "name": "Recommended", "enabled": True},
141+
{"id": "watchly.loved", "name": "Because You Loved", "enabled": True},
142+
{"id": "watchly.watched", "name": "Because You Watched", "enabled": True},
143+
{"id": "watchly.theme", "name": "Genre & Theme Collections", "enabled": True},
144+
],
145+
"language": "en",
146+
"rpdb_key": "",
147+
"excluded_movie_genres": [],
148+
"excluded_series_genres": [],
149+
},
150+
}
151+
152+
153+
async def store_payload(client: redis.Redis, email: str, user_id: str, auth_key: str):
154+
payload = prepare_default_payload(email, user_id)
155+
logger.info(f"Storing payload for {user_id}: {payload}")
156+
try:
157+
# encrypt auth_key
158+
if auth_key:
159+
payload["authKey"] = encrypt_auth_key(auth_key)
160+
key = user_id.strip()
161+
await client.set(key, json.dumps(payload))
162+
except (redis.RedisError, OSError) as exc:
163+
logger.warning(f"Failed to store payload for {key}: {exc}")
164+
165+
166+
async def process_migration_key(redis_client: redis.Redis, key: str) -> bool:
167+
try:
168+
try:
169+
data_raw = await redis_client.get(key)
170+
except (redis.RedisError, OSError) as exc:
171+
logger.warning(f"Failed to fetch payload for {key}: {exc}")
172+
return False
173+
174+
if not data_raw:
175+
logger.warning(f"Failed to fetch payload for {key}: Empty data")
176+
return False
177+
178+
try:
179+
payload = await decode_old_payloads(data_raw)
180+
except (json.JSONDecodeError, Exception) as exc:
181+
logger.warning(f"Failed to decode payload for key {key}: {exc}")
182+
return False
183+
184+
if payload.get("username") and payload.get("password"):
185+
auth_key = await get_auth_key(payload["username"], payload["password"])
186+
elif payload.get("authKey"):
187+
auth_key = payload.get("authKey")
188+
else:
189+
logger.warning(f"Failed to migrate {key}")
190+
await redis_client.delete(key)
191+
return False
192+
193+
email, user_id = await get_user_info(auth_key)
194+
if not email or not user_id:
195+
logger.warning(f"Failed to migrate {key}")
196+
await redis_client.delete(key)
197+
return False
198+
199+
new_payload = prepare_default_payload(email, user_id)
200+
if auth_key:
201+
new_payload["authKey"] = await encrypt_auth_key(auth_key)
202+
203+
new_key = user_id.strip()
204+
payload_json = json.dumps(new_payload)
205+
206+
if settings.TOKEN_TTL_SECONDS and settings.TOKEN_TTL_SECONDS > 0:
207+
set_success = await redis_client.set(new_key, payload_json, ex=settings.TOKEN_TTL_SECONDS, nx=True)
208+
if set_success:
209+
logger.info(
210+
f"Stored encrypted credential payload with TTL {settings.TOKEN_TTL_SECONDS} seconds (SETNX)"
211+
)
212+
else:
213+
set_success = await redis_client.setnx(new_key, payload_json)
214+
if set_success:
215+
logger.info("Stored encrypted credential payload without expiration (SETNX)")
216+
217+
if not set_success:
218+
logger.info(f"Credential payload for {new_key} already exists, not overwriting.")
219+
220+
await redis_client.delete(key)
221+
logger.info(f"Migrated {key} to {new_key}")
222+
return True
223+
224+
except Exception as exc:
225+
await redis_client.delete(key)
226+
traceback.print_exc()
227+
logger.warning(f"Failed to migrate {key}: {exc}")
228+
return False
229+
230+
231+
async def migrate_tokens():
232+
total_tokens = 0
233+
failed_tokens = 0
234+
success_tokens = 0
235+
try:
236+
redis_client = redis.from_url(settings.REDIS_URL, decode_responses=True, encoding="utf-8")
237+
except (redis.RedisError, OSError) as exc:
238+
logger.warning(f"Failed to connect to Redis: {exc}")
239+
return
240+
241+
pattern = f"{settings.REDIS_TOKEN_KEY}*"
242+
async for key in redis_client.scan_iter(match=pattern):
243+
total_tokens += 1
244+
if await process_migration_key(redis_client, key):
245+
success_tokens += 1
246+
else:
247+
failed_tokens += 1
248+
249+
logger.info(f"[STATS] Total: {total_tokens}, Failed: {failed_tokens}, Success: {success_tokens}")

docker-compose.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,6 @@ services:
1414
# some macos users need to use 127.0.0.1:8000:8000 to access the app on localhost due to IPV6 issues
1515
env_file:
1616
- .env
17-
environment:
18-
# Override REDIS_URL to use Docker service name instead of localhost
19-
- REDIS_URL=redis://redis:6379/0
2017
volumes:
2118
- ./:/app
2219
depends_on:

0 commit comments

Comments
 (0)