mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-05-16 21:59:26 +00:00
feat(session): add IP validation for session access and enhance session management
This commit is contained in:
@@ -1383,6 +1383,7 @@ async def session_create_handler(data: Dict[str, Any], request: Optional[web.Req
|
||||
service_instance,
|
||||
session_id=session_id,
|
||||
)
|
||||
session.creator_ip = request.remote if request else None
|
||||
session.cache_tag = session_cache_tag
|
||||
|
||||
return web.json_response(
|
||||
@@ -1411,15 +1412,7 @@ async def session_titles_handler(session_id: str, request: Optional[web.Request]
|
||||
interactive auth flows (OTP, captcha) can complete before titles
|
||||
are fetched.
|
||||
"""
|
||||
from unshackle.core.api.session_store import get_session_store
|
||||
|
||||
store = get_session_store()
|
||||
session = await store.get(session_id)
|
||||
if not session:
|
||||
raise APIError(
|
||||
APIErrorCode.SESSION_NOT_FOUND,
|
||||
f"Session not found: {session_id}",
|
||||
)
|
||||
session = await _get_validated_session(session_id, request)
|
||||
|
||||
try:
|
||||
service_instance = session.service_instance
|
||||
@@ -1464,15 +1457,7 @@ async def session_tracks_handler(
|
||||
This keeps auth separate from track fetching, allowing interactive
|
||||
auth flows (OTP, captcha) before any tracks are requested.
|
||||
"""
|
||||
from unshackle.core.api.session_store import get_session_store
|
||||
|
||||
store = get_session_store()
|
||||
session = await store.get(session_id)
|
||||
if not session:
|
||||
raise APIError(
|
||||
APIErrorCode.SESSION_NOT_FOUND,
|
||||
f"Session not found: {session_id}",
|
||||
)
|
||||
session = await _get_validated_session(session_id, request)
|
||||
|
||||
title_id = data.get("title_id")
|
||||
if not title_id:
|
||||
@@ -1586,16 +1571,7 @@ async def session_segments_handler(
|
||||
Returns segment URLs, init data, DRM info, and any headers/cookies
|
||||
needed for CDN download.
|
||||
"""
|
||||
from unshackle.core.api.session_store import get_session_store
|
||||
|
||||
store = get_session_store()
|
||||
session = await store.get(session_id)
|
||||
if not session:
|
||||
raise APIError(
|
||||
APIErrorCode.SESSION_NOT_FOUND,
|
||||
f"Session not found or expired: {session_id}",
|
||||
details={"session_id": session_id},
|
||||
)
|
||||
session = await _get_validated_session(session_id, request)
|
||||
|
||||
track_ids = data.get("track_ids", [])
|
||||
if not track_ids:
|
||||
@@ -1685,6 +1661,26 @@ async def session_segments_handler(
|
||||
)
|
||||
|
||||
|
||||
async def _get_validated_session(session_id: str, request: Optional[web.Request]) -> Any:
|
||||
"""Fetch a session and verify the requesting IP matches the creator."""
|
||||
from unshackle.core.api.session_store import get_session_store
|
||||
|
||||
store = get_session_store()
|
||||
session = await store.get(session_id)
|
||||
if not session:
|
||||
raise APIError(
|
||||
APIErrorCode.SESSION_NOT_FOUND,
|
||||
f"Session not found or expired: {session_id}",
|
||||
details={"session_id": session_id},
|
||||
)
|
||||
if session.creator_ip and request and request.remote != session.creator_ip:
|
||||
raise APIError(
|
||||
APIErrorCode.FORBIDDEN,
|
||||
"Session access denied",
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
def _resolve_handler_proxy(
|
||||
data: Dict[str, Any], normalized_service: str
|
||||
) -> tuple[Optional[str], list]:
|
||||
@@ -1902,16 +1898,7 @@ async def session_license_handler(
|
||||
"""
|
||||
import base64
|
||||
|
||||
from unshackle.core.api.session_store import get_session_store
|
||||
|
||||
store = get_session_store()
|
||||
session = await store.get(session_id)
|
||||
if not session:
|
||||
raise APIError(
|
||||
APIErrorCode.SESSION_NOT_FOUND,
|
||||
f"Session not found or expired: {session_id}",
|
||||
details={"session_id": session_id},
|
||||
)
|
||||
session = await _get_validated_session(session_id, request)
|
||||
|
||||
track_id = data.get("track_id")
|
||||
track_ids = data.get("track_ids")
|
||||
@@ -2037,31 +2024,16 @@ async def session_license_handler(
|
||||
|
||||
async def session_info_handler(session_id: str, request: Optional[web.Request] = None) -> web.Response:
|
||||
"""Check session validity and get session info."""
|
||||
from datetime import timezone
|
||||
session = await _get_validated_session(session_id, request)
|
||||
|
||||
from unshackle.core.api.session_store import get_session_store
|
||||
|
||||
store = get_session_store()
|
||||
session = await store.get(session_id)
|
||||
if not session:
|
||||
raise APIError(
|
||||
APIErrorCode.SESSION_NOT_FOUND,
|
||||
f"Session not found or expired: {session_id}",
|
||||
details={"session_id": session_id},
|
||||
)
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
elapsed = (now - session.last_accessed).total_seconds()
|
||||
expires_in = max(0, store._ttl - int(elapsed))
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"session_id": session.session_id,
|
||||
"service": session.service_tag,
|
||||
"valid": True,
|
||||
"expires_in": expires_in,
|
||||
"expires_in": get_session_store()._ttl,
|
||||
"track_count": len(session.tracks),
|
||||
"title_count": len(session.title_map),
|
||||
}
|
||||
@@ -2075,14 +2047,8 @@ async def session_delete_handler(session_id: str, request: Optional[web.Request]
|
||||
from unshackle.core.api.session_store import get_session_store
|
||||
from unshackle.core.config import config as app_config
|
||||
|
||||
session = await _get_validated_session(session_id, request)
|
||||
store = get_session_store()
|
||||
session = await store.get(session_id)
|
||||
if not session:
|
||||
raise APIError(
|
||||
APIErrorCode.SESSION_NOT_FOUND,
|
||||
f"Session not found: {session_id}",
|
||||
details={"session_id": session_id},
|
||||
)
|
||||
|
||||
cache_tag = session.cache_tag
|
||||
await store.delete(session_id)
|
||||
|
||||
@@ -31,6 +31,7 @@ class SessionEntry:
|
||||
tracks: Dict[str, Track] = field(default_factory=dict) # track_id -> Track object
|
||||
tracks_by_title: Dict[str, Dict[str, Track]] = field(default_factory=dict) # title_key -> {track_id -> Track}
|
||||
chapters_by_title: Dict[str, List[Any]] = field(default_factory=dict) # title_key -> [Chapter]
|
||||
creator_ip: Optional[str] = None
|
||||
cache_tag: Optional[str] = None # per-session cache directory tag
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
last_accessed: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
@@ -51,7 +52,7 @@ class SessionStore:
|
||||
@property
|
||||
def _ttl(self) -> int:
|
||||
"""Session TTL in seconds from config."""
|
||||
return config.serve.get("session_ttl", 900) # 15 min default
|
||||
return config.serve.get("session_ttl", 300) # 5 min default
|
||||
|
||||
@property
|
||||
def _max_sessions(self) -> int:
|
||||
|
||||
@@ -442,8 +442,22 @@ subtitle:
|
||||
sidecar_format: srt
|
||||
|
||||
# Configuration for pywidevine and pyplayready's serve functionality
|
||||
# Also used for remote services (unshackle serve)
|
||||
serve:
|
||||
api_secret: "your-secret-key-here"
|
||||
|
||||
# Compression level for API payloads (manifests, cache, cookies)
|
||||
# 0=off, 1=fast, 6=balanced, 9=max compression (default: 1)
|
||||
compression_level: 1
|
||||
|
||||
# Session inactivity timeout in seconds (default: 300 = 5 minutes)
|
||||
# Sessions are automatically deleted after this many seconds of inactivity
|
||||
# Each API request resets the timer
|
||||
session_ttl: 300
|
||||
|
||||
# Maximum concurrent sessions before oldest is evicted (default: 100)
|
||||
max_sessions: 100
|
||||
|
||||
users:
|
||||
secret_key_for_user:
|
||||
devices: # Widevine devices (WVDs) this user can access
|
||||
@@ -456,6 +470,42 @@ serve:
|
||||
# playready_devices: # PlayReady device paths (auto-populated from directories.prds)
|
||||
# - '/path/to/device.prd'
|
||||
|
||||
# Remote Services Configuration
|
||||
# Connect to a remote unshackle server (unshackle serve) to use its services
|
||||
# without needing the service code locally. Use with: unshackle dl --remote
|
||||
# If multiple servers are configured, specify which with: --server <name>
|
||||
remote_services:
|
||||
# Server name (used with --server flag if multiple configured)
|
||||
my-server:
|
||||
url: "http://192.168.1.100:8786"
|
||||
api_key: "your-secret-key-here"
|
||||
|
||||
# Server-CDM mode: server handles all DRM licensing using its own CDM devices
|
||||
# When false (default), client uses its own CDM and proxies license requests through the server
|
||||
server_cdm: false
|
||||
|
||||
# Per-service overrides for remote services
|
||||
# Override downloader, decryption tool, or CDM settings per service on the client
|
||||
services:
|
||||
# Example: Use n_m3u8dl_re for HLS services, mp4decrypt for specific services
|
||||
# EXAMPLE_SERVICE:
|
||||
# downloader: n_m3u8dl_re # Override client downloader (requests, aria2c, n_m3u8dl_re, curl_impersonate)
|
||||
# decryption: mp4decrypt # Override decryption tool (shaka, mp4decrypt)
|
||||
|
||||
# Example: Multiple servers
|
||||
# us-server:
|
||||
# url: "https://us.example.com:8786"
|
||||
# api_key: "us-api-key"
|
||||
# server_cdm: true
|
||||
# services:
|
||||
# EXAMPLE:
|
||||
# downloader: n_m3u8dl_re
|
||||
# decryption: mp4decrypt
|
||||
# eu-server:
|
||||
# url: "https://eu.example.com:8786"
|
||||
# api_key: "eu-api-key"
|
||||
# server_cdm: false
|
||||
|
||||
# Configuration data for each Service
|
||||
services:
|
||||
# Service-specific configuration goes here
|
||||
|
||||
Reference in New Issue
Block a user