Skip to content

Commit 3b503cb

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

3 files changed

Lines changed: 174 additions & 4 deletions

File tree

backend/open_webui/routers/utils.py

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import black
2+
import ipaddress
23
import logging
34
import markdown
5+
import socket
6+
7+
import aiohttp
8+
from bs4 import BeautifulSoup
9+
from urllib.parse import urlparse, urljoin
410

511
from open_webui.models.chats import ChatTitleMessagesForm
612
from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT
713
from open_webui.constants import ERROR_MESSAGES
814
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
915
from pydantic import BaseModel
10-
from starlette.responses import FileResponse
16+
from starlette.responses import FileResponse, RedirectResponse
1117

1218

1319
from open_webui.utils.misc import get_gravatar_url
@@ -19,6 +25,164 @@
1925

2026
router = APIRouter()
2127

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

23187
@router.get('/gravatar')
24188
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)