Skip to content

Commit 7676e66

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

3 files changed

Lines changed: 138 additions & 4 deletions

File tree

backend/open_webui/routers/utils.py

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
import logging
33
import markdown
44

5+
import aiohttp
6+
from bs4 import BeautifulSoup
7+
from urllib.parse import urlparse, urljoin
8+
59
from open_webui.models.chats import ChatTitleMessagesForm
610
from open_webui.config import DATA_DIR, ENABLE_ADMIN_EXPORT
711
from open_webui.constants import ERROR_MESSAGES
812
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
913
from pydantic import BaseModel
10-
from starlette.responses import FileResponse
14+
from starlette.responses import FileResponse, RedirectResponse
1115

1216

1317
from open_webui.utils.misc import get_gravatar_url
@@ -19,6 +23,130 @@
1923

2024
router = APIRouter()
2125

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

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