diff --git a/.env.example b/.env.example index 3d1146c..018d2cd 100644 --- a/.env.example +++ b/.env.example @@ -3,13 +3,15 @@ PORT=8000 ADDON_ID=com.bimal.watchly ADDON_NAME=Watchly REDIS_URL=redis://redis:6379/0 -TOKEN_SALT=replace-with-a-long-random-string TOKEN_TTL_SECONDS=0 ANNOUNCEMENT_HTML= HOST_NAME= RECOMMENDATION_SOURCE_ITEMS_LIMIT=10 # fetches recent watched/loved 10 movies and series to recommend based on those - +TOKEN_SALT=change-me +# generate some very long random string preferrably using cryptography libraries # UPDATER -CATALOG_UPDATE_MODE=cron +CATALOG_UPDATE_MODE=cron # Available options: cron, interval +# cron updates catalogs at specified times +# interval updates in specific intervals CATALOG_UPDATE_CRON_SCHEDULES=[{"hour": 12, "minute": 0, "id": "catalog_refresh_noon"},{"hour": 0, "minute": 0, "id": "catalog_refresh_midnight"}] CATALOG_REFRESH_INTERVAL_SECONDS=6*60*60 diff --git a/app/api/endpoints/catalogs.py b/app/api/endpoints/catalogs.py index 378a865..729f1d3 100644 --- a/app/api/endpoints/catalogs.py +++ b/app/api/endpoints/catalogs.py @@ -3,38 +3,27 @@ from fastapi import APIRouter, HTTPException, Response from loguru import logger -from app.core.settings import decode_settings +from app.core.security import redact_token +from app.core.settings import UserSettings, get_default_settings from app.services.catalog_updater import refresh_catalogs_for_credentials from app.services.recommendation_service import RecommendationService from app.services.stremio_service import StremioService -from app.utils import redact_token, resolve_user_credentials +from app.services.token_store import token_store + +MAX_RESULTS = 50 +SOURCE_ITEMS_LIMIT = 10 router = APIRouter() -@router.get("/catalog/{type}/{id}.json") @router.get("/{token}/catalog/{type}/{id}.json") -@router.get("/{settings_str}/{token}/catalog/{type}/{id}.json") -async def get_catalog( - type: str, - id: str, - response: Response, - token: str | None = None, - settings_str: str | None = None, -): - """ - Stremio catalog endpoint for movies and series. - """ +async def get_catalog(type: str, id: str, response: Response, token: str): if not token: raise HTTPException( status_code=400, detail="Missing credentials token. Please open Watchly from a configured manifest URL.", ) - logger.info(f"[{redact_token(token)}] Fetching catalog for {type} with id {id}") - - credentials = await resolve_user_credentials(token) - if type not in ["movie", "series"]: logger.warning(f"Invalid type: {type}") raise HTTPException(status_code=400, detail="Invalid type. Use 'movie' or 'series'") @@ -51,22 +40,25 @@ async def get_catalog( " specific item IDs." ), ) + + logger.info(f"[{redact_token(token)}] Fetching catalog for {type} with id {id}") + + credentials = await token_store.get_user_data(token) + if not credentials: + raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.") try: - # Decode settings to get language - user_settings = decode_settings(settings_str) if settings_str else None + # Extract settings from credentials + settings_dict = credentials.get("settings", {}) + user_settings = UserSettings(**settings_dict) if settings_dict else get_default_settings() language = user_settings.language if user_settings else "en-US" # Create services with credentials - stremio_service = StremioService( - username=credentials.get("username") or "", - password=credentials.get("password") or "", - auth_key=credentials.get("authKey"), - ) + stremio_service = StremioService(auth_key=credentials.get("authKey")) recommendation_service = RecommendationService( stremio_service=stremio_service, language=language, user_settings=user_settings ) - # Handle item-based recommendations (legacy or explicit link) + # Handle item-based recommendations if id.startswith("tt"): recommendations = await recommendation_service.get_recommendations_for_item(item_id=id) logger.info(f"Found {len(recommendations)} recommendations for {id}") @@ -84,12 +76,8 @@ async def get_catalog( logger.info(f"Found {len(recommendations)} recommendations for theme {id}") else: - # Top Picks (watchly.rec) - recommendations = await recommendation_service.get_recommendations( - content_type=type, - source_items_limit=10, - max_results=50, + content_type=type, source_items_limit=SOURCE_ITEMS_LIMIT, max_results=MAX_RESULTS ) logger.info(f"Found {len(recommendations)} recommendations for {type}") @@ -101,7 +89,7 @@ async def get_catalog( except HTTPException: raise except Exception as e: - logger.error(f"[{redact_token(token)}] Error fetching catalog for {type}/{id}: {e}", exc_info=True) + logger.exception(f"[{redact_token(token)}] Error fetching catalog for {type}/{id}: {e}") raise HTTPException(status_code=500, detail=str(e)) @@ -111,9 +99,9 @@ async def update_catalogs(token: str): Update the catalogs for the addon. This is a manual endpoint to update the catalogs. """ # Decode credentials from path - credentials = await resolve_user_credentials(token) + credentials = await token_store.get_user_data(token) logger.info(f"[{redact_token(token)}] Updating catalogs in response to manual request") - updated = await refresh_catalogs_for_credentials(credentials) + updated = await refresh_catalogs_for_credentials(token, credentials) logger.info(f"Manual catalog update completed: {updated}") return {"success": updated} diff --git a/app/api/endpoints/manifest.py b/app/api/endpoints/manifest.py index 3673798..138c770 100644 --- a/app/api/endpoints/manifest.py +++ b/app/api/endpoints/manifest.py @@ -1,14 +1,14 @@ from async_lru import alru_cache -from fastapi import Response +from fastapi import HTTPException, Response from fastapi.routing import APIRouter from app.core.config import settings -from app.core.settings import UserSettings, decode_settings +from app.core.settings import UserSettings, get_default_settings from app.core.version import __version__ from app.services.catalog import DynamicCatalogService from app.services.stremio_service import StremioService +from app.services.token_store import token_store from app.services.translation import translation_service -from app.utils import resolve_user_credentials router = APIRouter() @@ -55,27 +55,19 @@ def get_base_manifest(user_settings: UserSettings | None = None): } -# Cache catalog definitions for 1 hour (3600s) # Cache catalog definitions for 1 hour (3600s) @alru_cache(maxsize=1000, ttl=3600) -async def fetch_catalogs(token: str | None = None, settings_str: str | None = None): - if not token: - return [] +async def fetch_catalogs(token: str): + credentials = await token_store.get_user_data(token) + if not credentials: + raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.") - credentials = await resolve_user_credentials(token) - - if settings_str: - user_settings = decode_settings(settings_str) - elif credentials.get("settings"): + if credentials.get("settings"): user_settings = UserSettings(**credentials["settings"]) else: - user_settings = None + user_settings = get_default_settings() - stremio_service = StremioService( - username=credentials.get("username") or "", - password=credentials.get("password") or "", - auth_key=credentials.get("authKey"), - ) + stremio_service = StremioService(auth_key=credentials.get("authKey")) # Note: get_library_items is expensive, but we need it to determine *which* genre catalogs to show. library_items = await stremio_service.get_library_items() @@ -88,75 +80,56 @@ async def fetch_catalogs(token: str | None = None, settings_str: str | None = No return catalogs -async def _manifest_handler(response: Response, token: str | None, settings_str: str | None): - """Stremio manifest handler.""" - # Cache manifest for 1 day (86400 seconds) +def get_config_id(catalog) -> str | None: + catalog_id = catalog.get("id", "") + if catalog_id.startswith("watchly.theme."): + return "watchly.theme" + if catalog_id.startswith("watchly.loved."): + return "watchly.loved" + if catalog_id.startswith("watchly.watched."): + return "watchly.watched" + if catalog_id.startswith("watchly.item."): + return "watchly.item" + if catalog_id.startswith("watchly.rec"): + return "watchly.rec" + return catalog_id + + +async def _manifest_handler(response: Response, token: str): response.headers["Cache-Control"] = "public, max-age=86400" + if not token: + raise HTTPException(status_code=401, detail="Missing token. Please reconfigure the addon.") + user_settings = None - if settings_str: - user_settings = decode_settings(settings_str) - elif token: - try: - creds = await resolve_user_credentials(token) - if creds.get("settings"): - user_settings = UserSettings(**creds["settings"]) - except Exception: - # Fallback to defaults if token resolution fails (or let it fail later in fetch_catalogs) - pass + try: + creds = await token_store.get_user_data(token) + if creds.get("settings"): + user_settings = UserSettings(**creds["settings"]) + except Exception: + raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.") base_manifest = get_base_manifest(user_settings) + # translate to target language if user_settings and user_settings.language: for cat in base_manifest.get("catalogs", []): if cat.get("name"): cat["name"] = await translation_service.translate(cat["name"], user_settings.language) - if token: - # We pass settings_str to fetch_catalogs so it can cache different versions - # We COPY the lists to avoid modifying cached objects or base_manifest defaults - fetched_catalogs = await fetch_catalogs(token, settings_str) - - # Create a new list with copies of all catalogs - all_catalogs = [c.copy() for c in base_manifest["catalogs"]] + [c.copy() for c in fetched_catalogs] - - if user_settings: - # Create a lookup for order index - order_map = {c.id: i for i, c in enumerate(user_settings.catalogs)} - - # Sort. Items not in map go to end. - # Extract config id from catalog id for matching with user settings - def get_config_id(catalog): - catalog_id = catalog.get("id", "") - if catalog_id.startswith("watchly.theme."): - return "watchly.theme" - if catalog_id.startswith("watchly.loved."): - return "watchly.loved" - if catalog_id.startswith("watchly.watched."): - return "watchly.watched" - if catalog_id.startswith("watchly.item."): - return "watchly.item" - if catalog_id.startswith("watchly.rec"): - return "watchly.rec" - return catalog_id - - all_catalogs.sort(key=lambda x: order_map.get(get_config_id(x), 999)) - - base_manifest["catalogs"] = all_catalogs + fetched_catalogs = await fetch_catalogs(token) - return base_manifest + all_catalogs = [c.copy() for c in base_manifest["catalogs"]] + [c.copy() for c in fetched_catalogs] + + if user_settings: + order_map = {c.id: i for i, c in enumerate(user_settings.catalogs)} + all_catalogs.sort(key=lambda x: order_map.get(get_config_id(x), 999)) + base_manifest["catalogs"] = all_catalogs -@router.get("/manifest.json") -async def manifest_root(response: Response): - return await _manifest_handler(response, None, None) + return base_manifest @router.get("/{token}/manifest.json") async def manifest_token(response: Response, token: str): - return await _manifest_handler(response, token, None) - - -@router.get("/{settings_str}/{token}/manifest.json") -async def manifest_settings(response: Response, settings_str: str, token: str): - return await _manifest_handler(response, token, settings_str) + return await _manifest_handler(response, token) diff --git a/app/api/endpoints/tokens.py b/app/api/endpoints/tokens.py index cd53017..53cdef5 100644 --- a/app/api/endpoints/tokens.py +++ b/app/api/endpoints/tokens.py @@ -5,11 +5,10 @@ from redis import exceptions as redis_exceptions from app.core.config import settings +from app.core.security import redact_token from app.core.settings import CatalogConfig, UserSettings, get_default_settings -from app.services.catalog_updater import refresh_catalogs_for_credentials from app.services.stremio_service import StremioService from app.services.token_store import token_store -from app.utils import redact_token router = APIRouter(prefix="/tokens", tags=["tokens"]) @@ -107,18 +106,18 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse excluded_series_genres=payload.excluded_series_genres, ) - # 4. Prepare payload to store + is_new_account = not existing_data + + # 4. Verify Stremio connection + verified_auth_key = await _verify_credentials_or_raise({"authKey": stremio_auth_key}) + + # 5. Prepare payload to store payload_to_store = { - "authKey": stremio_auth_key, + "authKey": verified_auth_key, "email": email, "settings": user_settings.model_dump(), } - is_new_account = not existing_data - - # 5. Verify Stremio connection - verified_auth_key = await _verify_credentials_or_raise({"authKey": stremio_auth_key}) - # 6. Store user data try: token = await token_store.store_user_data(user_id, payload_to_store) @@ -128,21 +127,6 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse except (redis_exceptions.RedisError, OSError) as exc: raise HTTPException(status_code=503, detail="Storage temporarily unavailable.") from exc - # 7. Refresh Catalogs - try: - await refresh_catalogs_for_credentials( - payload_to_store, user_settings=user_settings, auth_key=verified_auth_key - ) - except Exception as exc: - logger.error(f"Catalog refresh failed: {exc}") - if is_new_account: - # Rollback on new account creation failure - await token_store.delete_token(token) - raise HTTPException( - status_code=502, - detail="Credentials verified, but catalog refresh failed. Please try again.", - ) from exc - base_url = settings.HOST_NAME manifest_url = f"{base_url}/{token}/manifest.json" expires_in = settings.TOKEN_TTL_SECONDS if settings.TOKEN_TTL_SECONDS > 0 else None @@ -154,9 +138,7 @@ async def create_token(payload: TokenRequest, request: Request) -> TokenResponse ) -@router.post("/stremio-identity", status_code=200) -async def check_stremio_identity(payload: TokenRequest): - """Fetch user info from Stremio and check if account exists.""" +async def get_stremio_user_data(payload: TokenRequest) -> tuple[str, str]: auth_key = payload.authKey.strip() if payload.authKey else None if not auth_key: @@ -170,6 +152,7 @@ async def check_stremio_identity(payload: TokenRequest): user_info = await stremio_service.get_user_info() user_id = user_info["user_id"] email = user_info.get("email", "") + return user_id, email except Exception as e: logger.error(f"Stremio identity check failed: {e}") raise HTTPException( @@ -178,8 +161,13 @@ async def check_stremio_identity(payload: TokenRequest): finally: await stremio_service.close() - # Check existence + +@router.post("/stremio-identity", status_code=200) +async def check_stremio_identity(payload: TokenRequest): + """Fetch user info from Stremio and check if account exists.""" + user_id, email = await get_stremio_user_data(payload) try: + # Check existence token = token_store.get_token_from_user_id(user_id) user_data = await token_store.get_user_data(token) exists = bool(user_data) @@ -197,27 +185,8 @@ async def check_stremio_identity(payload: TokenRequest): @router.delete("/", status_code=200) async def delete_token(payload: TokenRequest): """Delete a token based on Stremio auth key.""" - stremio_auth_key = payload.authKey.strip() if payload.authKey else None - - if not stremio_auth_key: - raise HTTPException( - status_code=400, - detail="Stremio auth key is required to delete account.", - ) - - if stremio_auth_key.startswith('"') and stremio_auth_key.endswith('"'): - stremio_auth_key = stremio_auth_key[1:-1].strip() - try: - # Fetch user info from Stremio - stremio_service = StremioService(auth_key=stremio_auth_key) - try: - user_info = await stremio_service.get_user_info() - user_id = user_info["user_id"] - except Exception as e: - raise HTTPException(status_code=400, detail=f"Failed to verify Stremio identity: {e}") - finally: - await stremio_service.close() + user_id, _ = await get_stremio_user_data(payload) # Get token from user_id token = token_store.get_token_from_user_id(user_id) diff --git a/app/core/config.py b/app/core/config.py index dafa231..6cacbfa 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -20,8 +20,8 @@ class Settings(BaseSettings): ADDON_ID: str = "com.bimal.watchly" ADDON_NAME: str = "Watchly" REDIS_URL: str = "redis://redis:6379/0" - TOKEN_SALT: str = "change-me" REDIS_TOKEN_KEY: str = "watchly:token:" + TOKEN_SALT: str = "change-me" TOKEN_TTL_SECONDS: int = 0 # 0 = never expire ANNOUNCEMENT_HTML: str = "" AUTO_UPDATE_CATALOGS: bool = True diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..276fae0 --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,10 @@ +def redact_token(token: str | None) -> str: + """ + Redact a token for logging purposes. + Shows the first 6 characters followed by ***. + """ + if not token: + return "None" + if len(token) <= 6: + return token + return f"{token[:6]}***" diff --git a/app/core/settings.py b/app/core/settings.py index 6528ebb..10cc7f6 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -1,6 +1,3 @@ -import base64 -import zlib - from pydantic import BaseModel @@ -18,33 +15,6 @@ class UserSettings(BaseModel): excluded_series_genres: list[str] = [] -def encode_settings(settings: UserSettings) -> str: - json_str = settings.model_dump_json(exclude_defaults=True) - # Compress and then base64 encode to keep URL short - compressed = zlib.compress(json_str.encode("utf-8")) - encoded = base64.urlsafe_b64encode(compressed).decode("utf-8").rstrip("=") - return f"settings:{encoded}" - - -def decode_settings(settings_str: str) -> UserSettings: - try: - # Remove prefix if present - if settings_str.startswith("settings:"): - settings_str = settings_str[9:] - - # Add padding back if necessary - padding = 4 - (len(settings_str) % 4) - if padding != 4: - settings_str += "=" * padding - - compressed = base64.urlsafe_b64decode(settings_str) - json_str = zlib.decompress(compressed).decode("utf-8") - return UserSettings.model_validate_json(json_str) - except Exception: - # Fallback to default settings if decoding fails - return get_default_settings() - - def get_default_settings() -> UserSettings: return UserSettings( language="en-US", @@ -55,3 +25,9 @@ def get_default_settings() -> UserSettings: CatalogConfig(id="watchly.theme", name="Because of Genre/Theme", enabled=True), ], ) + + +class Credentials(BaseModel): + authKey: str + email: str + settings: UserSettings diff --git a/app/models/token.py b/app/models/token.py new file mode 100644 index 0000000..80f6b3a --- /dev/null +++ b/app/models/token.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + + +class UserSettings(BaseModel): + pass + + +class Credentials(BaseModel): + authKey: str + email: str + user_settings: UserSettings diff --git a/app/services/catalog_updater.py b/app/services/catalog_updater.py index 3a608da..ae144a8 100644 --- a/app/services/catalog_updater.py +++ b/app/services/catalog_updater.py @@ -4,37 +4,33 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.interval import IntervalTrigger +from fastapi import HTTPException from loguru import logger from app.core.config import settings -from app.core.settings import UserSettings +from app.core.security import redact_token +from app.core.settings import UserSettings, get_default_settings from app.services.catalog import DynamicCatalogService from app.services.stremio_service import StremioService from app.services.token_store import token_store -from app.utils import redact_token # Max number of concurrent updates to prevent overwhelming external APIs MAX_CONCURRENT_UPDATES = 5 -async def refresh_catalogs_for_credentials( - credentials: dict[str, Any], - user_settings: UserSettings | None = None, - auth_key: str | None = None, - key: str | None = None, -) -> bool: - """Regenerate catalogs for the provided credentials and push them to Stremio.""" - stremio_service = StremioService( - username=credentials.get("username") or "", - password=credentials.get("password") or "", - auth_key=auth_key or credentials.get("authKey"), - ) +async def refresh_catalogs_for_credentials(token: str, credentials: dict[str, Any]) -> bool: + if not credentials: + logger.warning(f"[{redact_token(token)}] Attempted to refresh catalogs with no credentials.") + raise HTTPException(status_code=401, detail="Invalid or expired token. Please reconfigure the addon.") + + auth_key = credentials.get("authKey") + stremio_service = StremioService(auth_key=auth_key) # check if user has addon installed or not try: - addon_installed = await stremio_service.is_addon_installed() + addon_installed = await stremio_service.is_addon_installed(auth_key) if not addon_installed: logger.info("User has not installed addon. Removing token from redis") - await token_store.delete_token(key=key) + await token_store.delete_token(key=token) return True except Exception as e: logger.exception(f"Failed to check if addon is installed: {e}") @@ -43,13 +39,18 @@ async def refresh_catalogs_for_credentials( library_items = await stremio_service.get_library_items() dynamic_catalog_service = DynamicCatalogService(stremio_service=stremio_service) + # Ensure user_settings is available + if credentials.get("settings"): + try: + user_settings = UserSettings(**credentials["settings"]) + except Exception as e: + user_settings = get_default_settings() + logger.warning(f"Failed to parse user settings from credentials: {e}") + catalogs = await dynamic_catalog_service.get_dynamic_catalogs( library_items=library_items, user_settings=user_settings ) - auth_key_or_username = credentials.get("authKey") or credentials.get("username") - redacted = redact_token(auth_key_or_username) if auth_key_or_username else "unknown" - logger.info(f"[{redacted}] Prepared {len(catalogs)} catalogs") - auth_key = await stremio_service.get_auth_key() + logger.info(f"[{redact_token(token)}] Prepared {len(catalogs)} catalogs") return await stremio_service.update_catalogs(catalogs, auth_key) except Exception as e: logger.exception(f"Failed to update catalogs: {e}", exc_info=True) @@ -116,20 +117,20 @@ async def refresh_all_tokens(self) -> None: sem = asyncio.Semaphore(MAX_CONCURRENT_UPDATES) async def _update_safe(key: str, payload: dict[str, Any]) -> None: - if not self._has_credentials(payload): + if not payload.get("authKey"): logger.debug( - f"Skipping token {self._mask_key(key)} with incomplete credentials", + f"Skipping token {redact_token(key)} with incomplete credentials", ) return async with sem: try: - updated = await refresh_catalogs_for_credentials(payload, key=key) + updated = await refresh_catalogs_for_credentials(key, payload) logger.info( - f"Background refresh for {self._mask_key(key)} completed (updated={updated})", + f"Background refresh for {redact_token(key)} completed (updated={updated})", ) except Exception as exc: - logger.error(f"Background refresh failed for {self._mask_key(key)}: {exc}", exc_info=True) + logger.error(f"Background refresh failed for {redact_token(key)}: {exc}", exc_info=True) try: async for key, payload in token_store.iter_payloads(): @@ -144,12 +145,3 @@ async def _update_safe(key: str, payload: dict[str, Any]) -> None: except Exception as exc: logger.error(f"Catalog refresh scan failed: {exc}", exc_info=True) - - @staticmethod - def _has_credentials(payload: dict[str, Any]) -> bool: - return bool(payload.get("authKey") or (payload.get("username") and payload.get("password"))) - - @staticmethod - def _mask_key(key: str) -> str: - suffix = key.split(":")[-1] - return f"***{suffix[-6:]}" diff --git a/app/services/recommendation_service.py b/app/services/recommendation_service.py index e54220b..9409d19 100644 --- a/app/services/recommendation_service.py +++ b/app/services/recommendation_service.py @@ -258,6 +258,7 @@ async def get_recommendations_for_item(self, item_id: str) -> list[dict]: # We need to detect content_type from item_id or media_type to know which exclusion list to use. # media_type is already resolved above. excluded_ids = set(self._get_excluded_genre_ids(media_type)) + if excluded_ids: recommendations = [ item for item in recommendations if not excluded_ids.intersection(item.get("genre_ids") or []) diff --git a/app/services/token_store.py b/app/services/token_store.py index 9cafadd..d3f8d98 100644 --- a/app/services/token_store.py +++ b/app/services/token_store.py @@ -1,5 +1,4 @@ import base64 -import hashlib import json from collections.abc import AsyncIterator from typing import Any @@ -7,9 +6,12 @@ import redis.asyncio as redis from cachetools import TTLCache from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from loguru import logger from app.core.config import settings +from app.core.security import redact_token class TokenStore: @@ -26,6 +28,7 @@ def __init__(self) -> None: if not settings.REDIS_URL: logger.warning("REDIS_URL is not set. Token storage will fail until a Redis instance is configured.") + if not settings.TOKEN_SALT or settings.TOKEN_SALT == "change-me": logger.warning( "TOKEN_SALT is missing or using the default placeholder. Set a strong value to secure tokens." @@ -40,14 +43,25 @@ def _ensure_secure_salt(self) -> None: def _get_cipher(self) -> Fernet: """Get or create Fernet cipher instance based on TOKEN_SALT.""" + salt = b"x7FDf9kypzQ1LmR32b8hWv49sKq2Pd8T" if self._cipher is None: - # Derive a 32-byte key from TOKEN_SALT using SHA256, then URL-safe base64 encode it - # This ensures we always have a valid Fernet key regardless of the salt's format - key_bytes = hashlib.sha256(settings.TOKEN_SALT.encode()).digest() - fernet_key = base64.urlsafe_b64encode(key_bytes) - self._cipher = Fernet(fernet_key) + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=200_000, + ) + + key = base64.urlsafe_b64encode(kdf.derive(settings.TOKEN_SALT.encode("utf-8"))) + self._cipher = Fernet(key) return self._cipher + def encrypt_token(self, token: str) -> str: + return self._cipher.encrypt(token.encode("utf-8")).decode("utf-8") + + def decrypt_token(self, enc: str) -> str: + return self._cipher.decrypt(enc.encode("utf-8")).decode("utf-8") + async def _get_client(self) -> redis.Redis: if self._client is None: self._client = redis.from_url(settings.REDIS_URL, decode_responses=True, encoding="utf-8") @@ -57,44 +71,26 @@ def _format_key(self, token: str) -> str: """Format Redis key from token.""" return f"{self.KEY_PREFIX}{token}" - def _encrypt_password(self, password: str) -> str: - """Encrypt password using Fernet.""" - if not password: - return None - return self._get_cipher().encrypt(password.encode()).decode("utf-8") - - def _decrypt_password(self, encrypted_password: str) -> str: - """Decrypt password using Fernet.""" - if not encrypted_password: - return None - try: - return self._get_cipher().decrypt(encrypted_password.encode()).decode("utf-8") - except InvalidToken: - return None - def get_token_from_user_id(self, user_id: str) -> str: - """Generate token from user_id (plain user_id as token).""" - if not user_id: - raise ValueError("User ID is required to generate token") - # Use user_id directly as token (no encryption) return user_id.strip() def get_user_id_from_token(self, token: str) -> str: - """Get user_id from token (they are the same now).""" return token.strip() if token else "" async def store_user_data(self, user_id: str, payload: dict[str, Any]) -> str: self._ensure_secure_salt() - token = self.get_token_from_user_id(user_id) key = self._format_key(token) - # Prepare data for storage (Plain JSON, no password encryption needed) + # Prepare data for storage (Plain JSON, no encryption needed) storage_data = payload.copy() # Store user_id in payload for convenience storage_data["user_id"] = user_id + if storage_data.get("authKey"): + storage_data["authKey"] = self.encrypt_token(storage_data["authKey"]) + client = await self._get_client() json_str = json.dumps(storage_data) @@ -121,25 +117,13 @@ async def get_user_data(self, token: str) -> dict[str, Any] | None: try: data = json.loads(data_raw) + if data.get("authKey"): + data["authKey"] = self.decrypt_token(data["authKey"]) self._payload_cache[token] = data return data - except json.JSONDecodeError: + except (json.JSONDecodeError, InvalidToken): return None - # Alias for compatibility with existing calls, but implementation changed - def derive_token(self, payload: dict[str, Any]) -> str: - # We can't really derive token from mixed payload anymore unless we have email. - # This might break existing calls in `tokens.py`. We need to fix `tokens.py` to use `get_token_from_email`. - raise NotImplementedError("Use get_token_from_email instead") - - async def get_payload(self, token: str) -> dict[str, Any] | None: - return await self.get_user_data(token) - - async def store_payload(self, payload: dict[str, Any]) -> tuple[str, bool]: - # This signature doesn't match new logic which needs email explicitly or inside payload. - # We will update tokens.py first. - raise NotImplementedError("Use store_user_data instead") - async def delete_token(self, token: str = None, key: str = None) -> None: if not token and not key: raise ValueError("Either token or key must be provided") @@ -154,7 +138,6 @@ async def delete_token(self, token: str = None, key: str = None) -> None: del self._payload_cache[token] async def iter_payloads(self) -> AsyncIterator[tuple[str, dict[str, Any]]]: - """Iterate over all stored payloads, yielding key and payload.""" try: client = await self._get_client() except (redis.RedisError, OSError) as exc: @@ -168,7 +151,7 @@ async def iter_payloads(self) -> AsyncIterator[tuple[str, dict[str, Any]]]: try: data_raw = await client.get(key) except (redis.RedisError, OSError) as exc: - logger.warning(f"Failed to fetch payload for {key}: {exc}") + logger.warning(f"Failed to fetch payload for {redact_token(key)}: {exc}") continue if not data_raw: @@ -177,7 +160,7 @@ async def iter_payloads(self) -> AsyncIterator[tuple[str, dict[str, Any]]]: try: payload = json.loads(data_raw) except json.JSONDecodeError: - logger.warning(f"Failed to decode payload for key {key}. Skipping.") + logger.warning(f"Failed to decode payload for key {redact_token(key)}. Skipping.") continue yield key, payload diff --git a/app/services/user_profile.py b/app/services/user_profile.py index 3d21ff8..07a3822 100644 --- a/app/services/user_profile.py +++ b/app/services/user_profile.py @@ -106,8 +106,6 @@ def calculate_similarity(self, profile: UserTasteProfile, item_meta: dict) -> fl score = 0.0 - print(profile) - # 1. GENRES # Normalize so movies with many genres don't get excessive score. for gid in item_vec["genres"]: diff --git a/app/utils.py b/app/utils.py deleted file mode 100644 index 822cbaf..0000000 --- a/app/utils.py +++ /dev/null @@ -1,59 +0,0 @@ -from typing import Any - -from fastapi import HTTPException - -from app.services.token_store import token_store - - -def redact_token(token: str | None, visible_chars: int = 8) -> str: - """ - Redact a token for logging purposes. - Shows first few characters followed by *** for debugging. - - Args: - token: The token to redact - visible_chars: Number of characters to show before redaction (default: 8) - - Returns: - Redacted token string (e.g., "ksfjads***" or "None" if token is None) - """ - if not token: - return "None" - if len(token) <= visible_chars: - return "***" - return f"{token[:visible_chars]}***" - - -async def resolve_user_credentials(token: str) -> dict[str, Any]: - """Resolve credentials from Redis token.""" - if not token: - raise HTTPException( - status_code=400, - detail="Missing credentials token. Please reinstall the addon.", - ) - - payload = await token_store.get_payload(token) - if not payload: - raise HTTPException( - status_code=401, - detail="Invalid or expired token. Please reconfigure the addon.", - ) - - include_watched = payload.get("includeWatched", False) - username = payload.get("username") - password = payload.get("password") - auth_key = payload.get("authKey") - - if not auth_key and (not username or not password): - raise HTTPException( - status_code=400, - detail="Stored token is missing credentials. Please reconfigure the addon.", - ) - - return { - "username": username, - "password": password, - "authKey": auth_key, - "includeWatched": include_watched, - "settings": payload.get("settings"), - }