From 6554c47bb48a1f8ecd57e5210de86bf8c1abb68d Mon Sep 17 00:00:00 2001 From: Algorithm5838 <108630393+Algorithm5838@users.noreply.github.com> Date: Tue, 14 Apr 2026 02:24:27 +0300 Subject: [PATCH] feat: replace Google favicon API with backend proxy --- backend/open_webui/routers/utils.py | 178 +++++++++++++++++- .../components/chat/Messages/Citations.svelte | 2 +- .../ResponseMessage/WebSearchResults.svelte | 10 +- 3 files changed, 186 insertions(+), 4 deletions(-) diff --git a/backend/open_webui/routers/utils.py b/backend/open_webui/routers/utils.py index 20705c2c44..f1921a691c 100644 --- a/backend/open_webui/routers/utils.py +++ b/backend/open_webui/routers/utils.py @@ -1,13 +1,20 @@ +import asyncio import black +import ipaddress import logging import markdown +import socket + +import aiohttp +from bs4 import BeautifulSoup +from urllib.parse import urlparse, urljoin from open_webui.models.chats import ChatTitleMessagesForm from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT from open_webui.constants import ERROR_MESSAGES from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from pydantic import BaseModel -from starlette.responses import FileResponse +from starlette.responses import FileResponse, RedirectResponse from open_webui.utils.misc import get_gravatar_url @@ -19,6 +26,175 @@ log = logging.getLogger(__name__) router = APIRouter() +_FAVICON_CACHE: dict[str, bytes] = {} +_FAVICON_CACHE_MAX = 1024 +_FAVICON_MAX_BYTES = 200 * 1024 # 200 KB — skip oversized responses +_FAVICON_HEADERS = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' +} +_FAVICON_TIMEOUT = aiohttp.ClientTimeout(total=5) + + +async def _is_safe_host(hostname: str) -> bool: + """Return True only if the hostname resolves to a public IP (not private/loopback/link-local).""" + try: + addrs = await asyncio.to_thread(socket.getaddrinfo, hostname, None) + for addr in addrs: + ip = ipaddress.ip_address(addr[4][0]) + if ( + ip.is_loopback + or ip.is_private + or ip.is_link_local + or ip.is_reserved + or ip.is_multicast + ): + return False + return True + except Exception: + return False + + +def _extract_domain(url: str) -> str | None: + try: + parsed = urlparse(url if url.startswith('http') else f'https://{url}') + return parsed.netloc or parsed.path.split('/')[0] or None + except Exception: + return None + + +def _resolve_href(href: str, base: str) -> str: + """Resolve a potentially relative favicon href to an absolute URL.""" + if href.startswith('data:'): + return '' + return urljoin(base, href) + + +def _pick_best_icon(links: list) -> str: + """ + Pick the best favicon from a list of tags. + Priority: SVG > PNG/WebP > ICO, then larger declared size wins. + """ + FORMAT_RANK = {'svg': 3, 'png': 2, 'webp': 2, 'gif': 1, 'ico': 0} + + def score(tag): + href = tag.get('href', '') + mime = (tag.get('type') or '').lower() + ext = href.rsplit('.', 1)[-1].lower().split('?')[0] if '.' in href else '' + fmt = ext or mime.split('/')[-1] + fmt_score = FORMAT_RANK.get(fmt, 1) + + sizes = tag.get('sizes', '') + try: + w = int(sizes.split('x')[0]) if sizes and sizes != 'any' else 0 + except (ValueError, IndexError): + w = 0 + return (fmt_score, w) + + links = [t for t in links if t.get('href')] + if not links: + return '' + return max(links, key=score).get('href', '') + + +async def _fetch_favicon_bytes(domain: str) -> bytes | None: + if not await _is_safe_host(domain): + log.debug('favicon: rejected non-public host %s', domain) + return None + + base = f'https://{domain}' + + async with aiohttp.ClientSession(headers=_FAVICON_HEADERS, timeout=_FAVICON_TIMEOUT) as session: + # Step 1: try /favicon.ico directly + try: + async with session.get(f'{base}/favicon.ico', allow_redirects=True) as r: + if r.status == 200 and 'image' in r.content_type: + if r.content_length and r.content_length > _FAVICON_MAX_BYTES: + log.debug('favicon step 1: response too large for %s', domain) + else: + data = await r.read() + if len(data) <= _FAVICON_MAX_BYTES: + return data + except Exception as e: + log.debug('favicon step 1 failed for %s: %s', domain, e) + + # Step 2: parse HTML
for tags + try: + async with session.get(base, allow_redirects=True) as r: + if r.status == 200: + html = await r.text(errors='replace') + soup = BeautifulSoup(html, 'html.parser') + head = soup.head or soup + + rel_values = {'icon', 'shortcut icon', 'apple-touch-icon', 'apple-touch-icon-precomposed'} + links = [ + tag for tag in head.find_all('link', rel=True) + if set(tag['rel']) & rel_values + ] + + href = _pick_best_icon(links) + if href: + icon_url = _resolve_href(href, str(r.url)) + if icon_url: + icon_host = urlparse(icon_url).netloc.split(':')[0] + if icon_host and not await _is_safe_host(icon_host): + log.debug('favicon: rejected non-public icon host %s', icon_host) + else: + async with session.get(icon_url, allow_redirects=True) as ir: + if ir.status == 200 and 'image' in ir.content_type: + if ir.content_length and ir.content_length > _FAVICON_MAX_BYTES: + log.debug('favicon step 2: icon response too large for %s', icon_host) + else: + data = await ir.read() + if len(data) <= _FAVICON_MAX_BYTES: + return data + except Exception as e: + log.debug('favicon step 2 failed for %s: %s', domain, e) + + return None + + +@router.get('/favicon') +async def get_favicon(url: str, user=Depends(get_verified_user)): + """ + Proxy a source website's favicon server-side. + Results are cached in memory for the lifetime of the process. + Falls back to Open WebUI's own favicon if none can be found. + """ + domain = _extract_domain(url) + if not domain: + return RedirectResponse('/favicon.png') + + data = _FAVICON_CACHE.get(domain) + if data is None: + data = await _fetch_favicon_bytes(domain) + if data and len(_FAVICON_CACHE) < _FAVICON_CACHE_MAX: + _FAVICON_CACHE[domain] = data + + if not data: + return RedirectResponse('/favicon.png') + + # Detect content type from magic bytes + if data[:4] == b'\x89PNG': + ct = 'image/png' + elif data[:2] == b'\xff\xd8': + ct = 'image/jpeg' + elif data[:3] == b'GIF': + ct = 'image/gif' + elif data[:14].startswith(b'