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:
Paperclip CTO
2026-08-21 18:05:31 +02:00
parent 81450723f3
commit d1f2dd6acd

View File

@@ -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',