From d1f2dd6acd3c73aad7b0913b69d11d90e74dd115 Mon Sep 17 00:00:00 2001 From: Paperclip CTO Date: Fri, 21 Aug 2026 18:05:31 +0200 Subject: [PATCH] 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 --- webui/app.py | 122 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 120 insertions(+), 2 deletions(-) diff --git a/webui/app.py b/webui/app.py index bd74196..56cec5a 100644 --- a/webui/app.py +++ b/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',