Skip to content

Commit 9ca6ad6

Browse files
committed
feat: replace Google favicon API with backend proxy
1 parent 7816f8f commit 9ca6ad6

3 files changed

Lines changed: 186 additions & 4 deletions

File tree

backend/open_webui/routers/utils.py

Lines changed: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1+
import asyncio
12
import black
3+
import ipaddress
24
import logging
35
import markdown
6+
import socket
7+
8+
import aiohttp
9+
from bs4 import BeautifulSoup
10+
from urllib.parse import urlparse, urljoin
411

512
from open_webui.models.chats import ChatTitleMessagesForm
613
from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT
714
from open_webui.constants import ERROR_MESSAGES
815
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
916
from pydantic import BaseModel
10-
from starlette.responses import FileResponse
17+
from starlette.responses import FileResponse, RedirectResponse
1118

1219

1320
from open_webui.utils.misc import get_gravatar_url
@@ -19,6 +26,175 @@
1926

2027
router = APIRouter()
2128

29+
_FAVICON_CACHE: dict[str, bytes] = {}
30+
_FAVICON_CACHE_MAX = 1024
31+
_FAVICON_MAX_BYTES = 200 * 1024 # 200 KB — skip oversized responses
32+
_FAVICON_HEADERS = {
33+
'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'
34+
}
35+
_FAVICON_TIMEOUT = aiohttp.ClientTimeout(total=5)
36+
37+
38+
async def _is_safe_host(hostname: str) -> bool:
39+
"""Return True only if the hostname resolves to a public IP (not private/loopback/link-local)."""
40+
try:
41+
addrs = await asyncio.to_thread(socket.getaddrinfo, hostname, None)
42+
for addr in addrs:
43+
ip = ipaddress.ip_address(addr[4][0])
44+
if (
45+
ip.is_loopback
46+
or ip.is_private
47+
or ip.is_link_local
48+
or ip.is_reserved
49+
or ip.is_multicast
50+
):
51+
return False
52+
return True
53+
except Exception:
54+
return False
55+
56+
57+
def _extract_domain(url: str) -> str | None:
58+
try:
59+
parsed = urlparse(url if url.startswith('http') else f'https://{url}')
60+
return parsed.netloc or parsed.path.split('/')[0] or None
61+
except Exception:
62+
return None
63+
64+
65+
def _resolve_href(href: str, base: str) -> str:
66+
"""Resolve a potentially relative favicon href to an absolute URL."""
67+
if href.startswith('data:'):
68+
return ''
69+
return urljoin(base, href)
70+
71+
72+
def _pick_best_icon(links: list) -> str:
73+
"""
74+
Pick the best favicon from a list of <link> tags.
75+
Priority: SVG > PNG/WebP > ICO, then larger declared size wins.
76+
"""
77+
FORMAT_RANK = {'svg': 3, 'png': 2, 'webp': 2, 'gif': 1, 'ico': 0}
78+
79+
def score(tag):
80+
href = tag.get('href', '')
81+
mime = (tag.get('type') or '').lower()
82+
ext = href.rsplit('.', 1)[-1].lower().split('?')[0] if '.' in href else ''
83+
fmt = ext or mime.split('/')[-1]
84+
fmt_score = FORMAT_RANK.get(fmt, 1)
85+
86+
sizes = tag.get('sizes', '')
87+
try:
88+
w = int(sizes.split('x')[0]) if sizes and sizes != 'any' else 0
89+
except (ValueError, IndexError):
90+
w = 0
91+
return (fmt_score, w)
92+
93+
links = [t for t in links if t.get('href')]
94+
if not links:
95+
return ''
96+
return max(links, key=score).get('href', '')
97+
98+
99+
async def _fetch_favicon_bytes(domain: str) -> bytes | None:
100+
if not await _is_safe_host(domain):
101+
log.debug('favicon: rejected non-public host %s', domain)
102+
return None
103+
104+
base = f'https://{domain}'
105+
106+
async with aiohttp.ClientSession(headers=_FAVICON_HEADERS, timeout=_FAVICON_TIMEOUT) as session:
107+
# Step 1: try /favicon.ico directly
108+
try:
109+
async with session.get(f'{base}/favicon.ico', allow_redirects=True) as r:
110+
if r.status == 200 and 'image' in r.content_type:
111+
if r.content_length and r.content_length > _FAVICON_MAX_BYTES:
112+
log.debug('favicon step 1: response too large for %s', domain)
113+
else:
114+
data = await r.read()
115+
if len(data) <= _FAVICON_MAX_BYTES:
116+
return data
117+
except Exception as e:
118+
log.debug('favicon step 1 failed for %s: %s', domain, e)
119+
120+
# Step 2: parse HTML <head> for <link rel="icon"> tags
121+
try:
122+
async with session.get(base, allow_redirects=True) as r:
123+
if r.status == 200:
124+
html = await r.text(errors='replace')
125+
soup = BeautifulSoup(html, 'html.parser')
126+
head = soup.head or soup
127+
128+
rel_values = {'icon', 'shortcut icon', 'apple-touch-icon', 'apple-touch-icon-precomposed'}
129+
links = [
130+
tag for tag in head.find_all('link', rel=True)
131+
if set(tag['rel']) & rel_values
132+
]
133+
134+
href = _pick_best_icon(links)
135+
if href:
136+
icon_url = _resolve_href(href, str(r.url))
137+
if icon_url:
138+
icon_host = urlparse(icon_url).netloc.split(':')[0]
139+
if icon_host and not await _is_safe_host(icon_host):
140+
log.debug('favicon: rejected non-public icon host %s', icon_host)
141+
else:
142+
async with session.get(icon_url, allow_redirects=True) as ir:
143+
if ir.status == 200 and 'image' in ir.content_type:
144+
if ir.content_length and ir.content_length > _FAVICON_MAX_BYTES:
145+
log.debug('favicon step 2: icon response too large for %s', icon_host)
146+
else:
147+
data = await ir.read()
148+
if len(data) <= _FAVICON_MAX_BYTES:
149+
return data
150+
except Exception as e:
151+
log.debug('favicon step 2 failed for %s: %s', domain, e)
152+
153+
return None
154+
155+
156+
@router.get('/favicon')
157+
async def get_favicon(url: str, user=Depends(get_verified_user)):
158+
"""
159+
Proxy a source website's favicon server-side.
160+
Results are cached in memory for the lifetime of the process.
161+
Falls back to Open WebUI's own favicon if none can be found.
162+
"""
163+
domain = _extract_domain(url)
164+
if not domain:
165+
return RedirectResponse('/favicon.png')
166+
167+
data = _FAVICON_CACHE.get(domain)
168+
if data is None:
169+
data = await _fetch_favicon_bytes(domain)
170+
if data and len(_FAVICON_CACHE) < _FAVICON_CACHE_MAX:
171+
_FAVICON_CACHE[domain] = data
172+
173+
if not data:
174+
return RedirectResponse('/favicon.png')
175+
176+
# Detect content type from magic bytes
177+
if data[:4] == b'\x89PNG':
178+
ct = 'image/png'
179+
elif data[:2] == b'\xff\xd8':
180+
ct = 'image/jpeg'
181+
elif data[:3] == b'GIF':
182+
ct = 'image/gif'
183+
elif data[:14].startswith(b'<svg') or b'<svg' in data[:256]:
184+
ct = 'image/svg+xml'
185+
elif data[:4] == b'RIFF' and data[8:12] == b'WEBP':
186+
ct = 'image/webp'
187+
elif data[:4] == b'\x00\x00\x01\x00':
188+
ct = 'image/x-icon'
189+
else:
190+
ct = 'image/x-icon'
191+
192+
return Response(
193+
content=data,
194+
media_type=ct,
195+
headers={'Cache-Control': 'public, max-age=86400'},
196+
)
197+
22198

23199
@router.get('/gravatar')
24200
async def get_gravatar(email: str, user=Depends(get_verified_user)):

src/lib/components/chat/Messages/Citations.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@
175175
<div class="flex -space-x-1 items-center">
176176
{#each urlCitations.slice(0, 3) as citation, idx}
177177
<img
178-
src="https://www.google.com/s2/favicons?sz=32&domain={citation.source.name}"
178+
src="/api/v1/utils/favicon?url={encodeURIComponent(citation.source.name)}"
179179
alt="favicon"
180180
class="size-4 rounded-full shrink-0 border border-white dark:border-gray-850 bg-white dark:bg-gray-900"
181181
on:error={(e) => {

src/lib/components/chat/Messages/ResponseMessage/WebSearchResults.svelte

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,9 +66,12 @@
6666
<div class=" flex justify-center items-center gap-3">
6767
<div class="w-fit">
6868
<img
69-
src="https://www.google.com/s2/favicons?sz=32&domain={item.link}"
69+
src="/api/v1/utils/favicon?url={encodeURIComponent(item.link)}"
7070
alt="{item?.title ?? item.link} favicon"
7171
class="size-3.5"
72+
on:error={(e) => {
73+
e.currentTarget.src = '/favicon.png';
74+
}}
7275
/>
7376
</div>
7477

@@ -106,9 +109,12 @@
106109
<div class=" flex justify-center items-center gap-3">
107110
<div class="w-fit">
108111
<img
109-
src="https://www.google.com/s2/favicons?sz=32&domain={url}"
112+
src="/api/v1/utils/favicon?url={encodeURIComponent(url)}"
110113
alt="{url} favicon"
111114
class="size-3.5"
115+
on:error={(e) => {
116+
e.currentTarget.src = '/favicon.png';
117+
}}
112118
/>
113119
</div>
114120

0 commit comments

Comments
 (0)