fix: more thorough handling for internal image url fixes

This commit is contained in:
Antti Kettunen 2026-04-24 10:12:52 +03:00
parent 569c827ab4
commit 7285c6f55a
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7

View file

@ -18,7 +18,7 @@ import sqlite3
import types import types
import collections import collections
from pathlib import Path from pathlib import Path
from urllib.parse import urljoin from urllib.parse import quote, urljoin, urlparse
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from flask import Flask, render_template, request, jsonify, redirect, send_file, Response, session, g, abort 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 return jsonify({"success": False, "error": str(e)}), 500
def fix_artist_image_url(thumb_url): 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: if not thumb_url:
return None return None
@ -11136,6 +11136,11 @@ def fix_artist_image_url(thumb_url):
needs_fixing = ( needs_fixing = (
thumb_url.startswith('http://localhost:') or thumb_url.startswith('http://localhost:') or
thumb_url.startswith('https://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('/library/') or # Plex relative paths
thumb_url.startswith('/Items/') or # Jellyfin relative paths thumb_url.startswith('/Items/') or # Jellyfin relative paths
thumb_url.startswith('/api/') or # Old Navidrome API 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 # Construct proper Plex URL with token
fixed_url = f"{plex_base_url.rstrip('/')}{path}?X-Plex-Token={plex_token}" fixed_url = f"{plex_base_url.rstrip('/')}{path}?X-Plex-Token={plex_token}"
logger.info(f"Fixed URL: {fixed_url}") logger.info(f"Fixed URL: {fixed_url}")
return fixed_url return _browser_safe_image_url(fixed_url)
elif active_server == 'jellyfin': elif active_server == 'jellyfin':
jellyfin_config = config_manager.get_jellyfin_config() jellyfin_config = config_manager.get_jellyfin_config()
@ -11192,7 +11197,7 @@ def fix_artist_image_url(thumb_url):
else: else:
fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}" fixed_url = f"{jellyfin_base_url.rstrip('/')}{path}"
logger.info(f"Fixed URL: {fixed_url}") logger.info(f"Fixed URL: {fixed_url}")
return fixed_url return _browser_safe_image_url(fixed_url)
elif active_server == 'navidrome': elif active_server == 'navidrome':
navidrome_config = config_manager.get_navidrome_config() navidrome_config = config_manager.get_navidrome_config()
@ -11225,16 +11230,57 @@ def fix_artist_image_url(thumb_url):
# Construct proper Navidrome Subsonic URL # Construct proper Navidrome Subsonic URL
fixed_url = f"{navidrome_base_url.rstrip('/')}{path}{separator}{auth_params}" fixed_url = f"{navidrome_base_url.rstrip('/')}{path}{separator}{auth_params}"
logger.info(f"Fixed URL: {fixed_url}") 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") logger.warning(f"No configuration found for {active_server} or unsupported server type")
# Return original URL if no fixing needed/possible # Return a browser-safe URL even if no server-specific rebuild was possible.
return thumb_url return _browser_safe_image_url(thumb_url)
except Exception as e: except Exception as e:
logger.error(f"Error fixing image URL '{thumb_url}': {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') @app.route('/api/library/history')
def get_library_history(): def get_library_history():
@ -45220,8 +45266,7 @@ def image_proxy():
url = request.args.get('url', '') url = request.args.get('url', '')
if not url or not url.startswith('http'): if not url or not url.startswith('http'):
return '', 400 return '', 400
# Only allow known image CDNs
from urllib.parse import urlparse
host = urlparse(url).hostname or '' host = urlparse(url).hostname or ''
allowed_hosts = [ allowed_hosts = [
'i.scdn.co', 'mosaic.scdn.co', # Spotify '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', 'is1-ssl.mzstatic.com', 'is2-ssl.mzstatic.com', 'is3-ssl.mzstatic.com',
'is4-ssl.mzstatic.com', 'is5-ssl.mzstatic.com', # iTunes/Apple 'is4-ssl.mzstatic.com', 'is5-ssl.mzstatic.com', # iTunes/Apple
'img.discogs.com', 'i.discogs.com', # Discogs '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 return '', 403
try: try:
resp = requests.get(url, timeout=10, stream=True, headers={ resp = requests.get(url, timeout=10, stream=True, headers={