mirror of
https://github.com/open-webui/open-webui.git
synced 2026-09-12 23:02:35 +00:00
feat: replace Google favicon API with backend proxy
This commit is contained in:
parent
c767bcaa73
commit
6554c47bb4
3 changed files with 186 additions and 4 deletions
|
|
@ -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 <link> 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 <head> for <link rel="icon"> 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'<svg') or b'<svg' in data[:256]:
|
||||
ct = 'image/svg+xml'
|
||||
elif data[:4] == b'RIFF' and data[8:12] == b'WEBP':
|
||||
ct = 'image/webp'
|
||||
elif data[:4] == b'\x00\x00\x01\x00':
|
||||
ct = 'image/x-icon'
|
||||
else:
|
||||
ct = 'image/x-icon'
|
||||
|
||||
return Response(
|
||||
content=data,
|
||||
media_type=ct,
|
||||
headers={'Cache-Control': 'public, max-age=86400'},
|
||||
)
|
||||
|
||||
|
||||
@router.get('/gravatar')
|
||||
async def get_gravatar(email: str, user=Depends(get_verified_user)):
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@
|
|||
<div class="flex -space-x-1 items-center">
|
||||
{#each urlCitations.slice(0, 3) as citation, idx}
|
||||
<img
|
||||
src="https://www.google.com/s2/favicons?sz=32&domain={citation.source.name}"
|
||||
src="/api/v1/utils/favicon?url={encodeURIComponent(citation.source.name)}"
|
||||
alt="favicon"
|
||||
class="size-4 rounded-full shrink-0 border border-white dark:border-gray-850 bg-white dark:bg-gray-900"
|
||||
on:error={(e) => {
|
||||
|
|
|
|||
|
|
@ -66,9 +66,12 @@
|
|||
<div class=" flex justify-center items-center gap-3">
|
||||
<div class="w-fit">
|
||||
<img
|
||||
src="https://www.google.com/s2/favicons?sz=32&domain={item.link}"
|
||||
src="/api/v1/utils/favicon?url={encodeURIComponent(item.link)}"
|
||||
alt="{item?.title ?? item.link} favicon"
|
||||
class="size-3.5"
|
||||
on:error={(e) => {
|
||||
e.currentTarget.src = '/favicon.png';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -106,9 +109,12 @@
|
|||
<div class=" flex justify-center items-center gap-3">
|
||||
<div class="w-fit">
|
||||
<img
|
||||
src="https://www.google.com/s2/favicons?sz=32&domain={url}"
|
||||
src="/api/v1/utils/favicon?url={encodeURIComponent(url)}"
|
||||
alt="{url} favicon"
|
||||
class="size-3.5"
|
||||
on:error={(e) => {
|
||||
e.currentTarget.src = '/favicon.png';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue