DAP-79: validate resolved WebUI streams
Resolve Internet Archive entries to direct media files, reject HTML/error responses before token registration, and preserve Range-aware media proxy validation. Co-Authored-By: OpenAI Codex <noreply@openai.com>
This commit is contained in:
122
webui/app.py
122
webui/app.py
@@ -19,7 +19,7 @@ import secrets
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from urllib.parse import parse_qsl, urlencode
|
||||
from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, quote
|
||||
|
||||
import requests
|
||||
|
||||
@@ -51,6 +51,13 @@ app = Flask(__name__, template_folder='templates')
|
||||
_SITE_MODULES = {} # identifier → module
|
||||
_STREAMS = {} # transient token → resolved stream data
|
||||
_STREAM_TTL = 60 * 30
|
||||
_MEDIA_EXTENSIONS = ('.mp4', '.m4v', '.mkv', '.webm', '.mov', '.avi', '.mpg', '.mpeg')
|
||||
_MEDIA_CONTENT_TYPES = (
|
||||
'video/',
|
||||
'application/vnd.apple.mpegurl',
|
||||
'application/x-mpegurl',
|
||||
'application/dash+xml',
|
||||
)
|
||||
|
||||
def _load_sites():
|
||||
"""Import every site plugin that has a _search() function."""
|
||||
@@ -136,20 +143,120 @@ def _hoster_name(hoster):
|
||||
return hoster.get('displayedName') or hoster.get('name') or hoster.get('host') or hoster.get('link', 'Stream')
|
||||
|
||||
|
||||
def _normalize_stream_url(url):
|
||||
if url.startswith(('/embed/', '/details/', '/download/')):
|
||||
return urljoin('https://archive.org', url)
|
||||
return url
|
||||
|
||||
|
||||
def _archive_identifier(url):
|
||||
parsed = urlparse(url)
|
||||
if parsed.netloc and 'archive.org' not in parsed.netloc:
|
||||
return ''
|
||||
parts = [part for part in parsed.path.split('/') if part]
|
||||
if len(parts) < 2 or parts[0] not in ('embed', 'details'):
|
||||
return ''
|
||||
return parts[1]
|
||||
|
||||
|
||||
def _archive_direct_stream(url):
|
||||
identifier = _archive_identifier(url)
|
||||
if not identifier:
|
||||
return ''
|
||||
|
||||
metadata_url = 'https://archive.org/metadata/%s' % quote(identifier, safe='')
|
||||
try:
|
||||
response = requests.get(metadata_url, timeout=15)
|
||||
response.raise_for_status()
|
||||
metadata = response.json()
|
||||
except (ValueError, requests.RequestException) as exc:
|
||||
logger.warning('Archive metadata lookup failed for %s: %s', identifier, exc)
|
||||
return ''
|
||||
|
||||
files = metadata.get('files') or []
|
||||
candidates = []
|
||||
preferred_formats = (
|
||||
('512kb mpeg4', 50),
|
||||
('h.264 ia', 40),
|
||||
('mpeg4', 35),
|
||||
('webm', 25),
|
||||
('matroska', 15),
|
||||
('quicktime', 10),
|
||||
)
|
||||
for item in files:
|
||||
name = item.get('name') or ''
|
||||
if not name.lower().endswith(_MEDIA_EXTENSIONS):
|
||||
continue
|
||||
fmt = (item.get('format') or '').lower()
|
||||
score = 0
|
||||
for preferred, format_score in preferred_formats:
|
||||
if preferred in fmt:
|
||||
score += format_score
|
||||
break
|
||||
if item.get('source') == 'derivative':
|
||||
score += 5
|
||||
try:
|
||||
size = int(item.get('size') or 0)
|
||||
except (TypeError, ValueError):
|
||||
size = 0
|
||||
candidates.append((score, size, name))
|
||||
|
||||
if not candidates:
|
||||
return ''
|
||||
|
||||
candidates.sort(reverse=True)
|
||||
filename = quote(candidates[0][2], safe='/')
|
||||
return 'https://archive.org/download/%s/%s' % (quote(identifier, safe=''), filename)
|
||||
|
||||
|
||||
def _resolve_stream_url(stream):
|
||||
stream_url = stream.get('streamUrl') or stream.get('link')
|
||||
if not stream_url:
|
||||
return ''
|
||||
stream_url = _normalize_stream_url(stream_url)
|
||||
archive_url = _archive_direct_stream(stream_url)
|
||||
if archive_url:
|
||||
return archive_url
|
||||
if stream.get('resolved'):
|
||||
return stream_url
|
||||
try:
|
||||
import resolveurl as resolver
|
||||
return resolver.resolve(stream_url)
|
||||
resolved_url = resolver.resolve(stream_url)
|
||||
if resolved_url:
|
||||
return _normalize_stream_url(resolved_url)
|
||||
return ''
|
||||
except Exception as exc:
|
||||
logger.warning('Resolver failed, returning original stream URL: %s', exc)
|
||||
return stream_url
|
||||
|
||||
|
||||
def _is_media_response(response):
|
||||
content_type = response.headers.get('Content-Type', '').split(';', 1)[0].strip().lower()
|
||||
if content_type in ('text/html', 'application/xhtml+xml'):
|
||||
return False
|
||||
if any(content_type.startswith(prefix) for prefix in _MEDIA_CONTENT_TYPES):
|
||||
return True
|
||||
path = urlparse(response.url).path.lower()
|
||||
return path.endswith(_MEDIA_EXTENSIONS)
|
||||
|
||||
|
||||
def _validate_stream_url(url, headers):
|
||||
probe_headers = dict(headers or {})
|
||||
probe_headers.setdefault('Range', 'bytes=0-0')
|
||||
try:
|
||||
response = requests.get(url, headers=probe_headers, stream=True, timeout=20)
|
||||
except requests.RequestException as exc:
|
||||
raise ValueError('Stream could not be opened') from exc
|
||||
try:
|
||||
if response.status_code >= 400:
|
||||
raise ValueError('Stream source returned HTTP %s' % response.status_code)
|
||||
if not _is_media_response(response):
|
||||
content_type = response.headers.get('Content-Type', 'unknown')
|
||||
raise ValueError('Resolved URL is not a playable media stream (%s)' % content_type)
|
||||
finally:
|
||||
response.close()
|
||||
|
||||
|
||||
def _extract_playback(result, hoster_index=0, part_index=0):
|
||||
site_id = result.get('siteId')
|
||||
function_name = result.get('function')
|
||||
@@ -196,6 +303,8 @@ def _extract_playback(result, hoster_index=0, part_index=0):
|
||||
stream_url = _resolve_stream_url(stream)
|
||||
if not stream_url:
|
||||
raise ValueError('Stream could not be resolved')
|
||||
clean_stream_url, stream_headers = _pipe_headers(stream_url)
|
||||
_validate_stream_url(clean_stream_url, stream_headers)
|
||||
|
||||
token = _register_stream(stream_url, result.get('title'))
|
||||
return {
|
||||
@@ -342,6 +451,15 @@ def api_stream(token):
|
||||
logger.warning('Stream request failed: %s', exc)
|
||||
return jsonify({'error': 'Stream could not be opened'}), 502
|
||||
|
||||
if upstream.status_code >= 400:
|
||||
status_code = upstream.status_code
|
||||
upstream.close()
|
||||
return jsonify({'error': 'Stream source returned HTTP %s' % status_code}), 502
|
||||
|
||||
if not _is_media_response(upstream):
|
||||
upstream.close()
|
||||
return jsonify({'error': 'Resolved URL is not a playable media stream'}), 502
|
||||
|
||||
excluded = {
|
||||
'connection', 'content-encoding', 'keep-alive', 'proxy-authenticate',
|
||||
'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade',
|
||||
|
||||
Reference in New Issue
Block a user