From 7285c6f55a623c6bc0d36ba26efc2e1275a8bb4d Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Fri, 24 Apr 2026 10:12:52 +0300 Subject: [PATCH] fix: more thorough handling for internal image url fixes --- web_server.py | 68 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 11 deletions(-) diff --git a/web_server.py b/web_server.py index c835715d..1e9c6196 100644 --- a/web_server.py +++ b/web_server.py @@ -18,7 +18,7 @@ import sqlite3 import types import collections from pathlib import Path -from urllib.parse import urljoin +from urllib.parse import quote, urljoin, urlparse from concurrent.futures import ThreadPoolExecutor, as_completed from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g, abort @@ -11127,7 +11127,7 @@ def maintain_search_history(): return jsonify({"success": False, "error": str(e)}), 500 def fix_artist_image_url(thumb_url): - """Convert localhost URLs to proper server URLs using config""" + """Convert media-server image URLs into browser-safe URLs.""" if not thumb_url: return None @@ -11136,6 +11136,11 @@ def fix_artist_image_url(thumb_url): needs_fixing = ( thumb_url.startswith('http://localhost:') or thumb_url.startswith('https://localhost:') or + thumb_url.startswith('http://127.0.0.1:') or + thumb_url.startswith('https://127.0.0.1:') or + thumb_url.startswith('http://host.docker.internal:') or + thumb_url.startswith('https://host.docker.internal:') or + (thumb_url.startswith('http://') and _is_internal_image_host(thumb_url)) or thumb_url.startswith('/library/') or # Plex relative paths thumb_url.startswith('/Items/') or # Jellyfin relative paths thumb_url.startswith('/api/') or # Old Navidrome API paths @@ -11166,7 +11171,7 @@ def fix_artist_image_url(thumb_url): # Construct proper Plex URL with token fixed_url = f"{plex_base_url.rstrip('/')}{path}?X-Plex-Token={plex_token}" logger.info(f"Fixed URL: {fixed_url}") - return fixed_url + return _browser_safe_image_url(fixed_url) elif active_server == 'jellyfin': jellyfin_config = config_manager.get_jellyfin_config() @@ -11192,7 +11197,7 @@ def fix_artist_image_url(thumb_url): else: fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}" logger.info(f"Fixed URL: {fixed_url}") - return fixed_url + return _browser_safe_image_url(fixed_url) elif active_server == 'navidrome': navidrome_config = config_manager.get_navidrome_config() @@ -11225,16 +11230,57 @@ def fix_artist_image_url(thumb_url): # Construct proper Navidrome Subsonic URL fixed_url = f"{navidrome_base_url.rstrip('/')}{path}{separator}{auth_params}" logger.info(f"Fixed URL: {fixed_url}") - return fixed_url + return _browser_safe_image_url(fixed_url) logger.warning(f"No configuration found for {active_server} or unsupported server type") - # Return original URL if no fixing needed/possible - return thumb_url + # Return a browser-safe URL even if no server-specific rebuild was possible. + return _browser_safe_image_url(thumb_url) except Exception as e: logger.error(f"Error fixing image URL '{thumb_url}': {e}") - return thumb_url + return _browser_safe_image_url(thumb_url) + + +def _is_internal_image_host(url: str) -> bool: + """Return True when an image URL points at a host the browser likely cannot reach directly.""" + try: + parsed = urlparse(url) + host = (parsed.hostname or '').strip('[]').lower() + if not host: + return False + + if host in {'localhost', '127.0.0.1', '::1', 'host.docker.internal'}: + return True + + # Single-label hosts are usually Docker service names or local LAN aliases. + if '.' not in host: + return True + + try: + ip = ipaddress.ip_address(host) + return ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved + except ValueError: + return False + except Exception: + return False + + +def _browser_safe_image_url(url: str) -> str: + """Return a browser-safe image URL, proxying internal hosts through SoulSync.""" + if not url: + return url + + if url.startswith('/api/image-proxy?url='): + return url + + if url.startswith('http://') or url.startswith('https://'): + if _is_internal_image_host(url): + return f"/api/image-proxy?url={quote(url, safe='')}" + return url + + # Relative media-server paths should already have been expanded before this point. + return url @app.route('/api/library/history') def get_library_history(): @@ -45220,8 +45266,7 @@ def image_proxy(): url = request.args.get('url', '') if not url or not url.startswith('http'): return '', 400 - # Only allow known image CDNs - from urllib.parse import urlparse + host = urlparse(url).hostname or '' allowed_hosts = [ 'i.scdn.co', 'mosaic.scdn.co', # Spotify @@ -45230,8 +45275,9 @@ def image_proxy(): 'is1-ssl.mzstatic.com', 'is2-ssl.mzstatic.com', 'is3-ssl.mzstatic.com', 'is4-ssl.mzstatic.com', 'is5-ssl.mzstatic.com', # iTunes/Apple 'img.discogs.com', 'i.discogs.com', # Discogs + 'localhost', '127.0.0.1', 'host.docker.internal', # Local/Docker media servers ] - if not any(host == h or host.endswith('.' + h) for h in allowed_hosts): + if not any(host == h or host.endswith('.' + h) for h in allowed_hosts) and not _is_internal_image_host(url): return '', 403 try: resp = requests.get(url, timeout=10, stream=True, headers={