"""Favicon сайта проекта: бэкенд-прокси с кэшем на диске.
Данные не покидают VPS (ТЗ 3.11): никаких сторонних favicon-сервисов, иконку
тянем с самого сайта проекта. `site_url` может задать агент через MCP, то есть
URL недоверенный: проверяем схему, резолвим хост и отвергаем непубличные адреса
(SSRF), редиректы проходим вручную с проверкой каждого хоста, ограничиваем
размер и тип. SVG не принимаем — скриптуемый формат (ср. FORBIDDEN_MIMES
в api/attachments.py: прямой переход на иконку в origin приложения = XSS).
Кэш — файлы в `settings.favicons_path`:
<sha1(url)>.<ext> — найденная иконка, ext из снаффа magic-bytes (TTL 7 дней)
<sha1(url)>.none — «иконки нет», маркер с коротким TTL (1 час)
Ключ глобальный (не per-user): сайт один и тот же для всех проектов.
"""
from __future__ import annotations
import asyncio
import hashlib
import ipaddress
import os
import re
import socket
import time
from pathlib import Path
from urllib.parse import urljoin, urlsplit
import httpx
from app.config import get_settings
from app.schemas import MAX_SITE_URL
CONNECT_TIMEOUT = 3.0
READ_TIMEOUT = 4.0
TOTAL_TIMEOUT = 6.0 # общий бюджет на одну иконку
MAX_BYTES = 256_000 # favicon больше не бывает; защита от бесконечного тела
MAX_HTML_BYTES = 256_000 # сколько читаем из <head> корня сайта
MAX_REDIRECTS = 3
CACHE_TTL = 7 * 24 * 3600.0 # найденная иконка живёт неделю
NEGATIVE_TTL = 3600.0 # «иконки нет» перепроверяем через час
MAX_CACHE_FILES = 1024 # защита от разрастания каталога
_UA = "gntodo/1.0 (project favicon)"
_EXT_MIME = {
".png": "image/png",
".jpg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".ico": "image/x-icon",
}
# Расширение определяем по содержимому: content-type от чужого сервера — не довод
_MAGIC: tuple[tuple[bytes, str], ...] = (
(b"\x89PNG\r\n\x1a\n", ".png"),
(b"\xff\xd8\xff", ".jpg"),
(b"GIF87a", ".gif"),
(b"GIF89a", ".gif"),
(b"\x00\x00\x01\x00", ".ico"),
)
_TAG_RE = re.compile(r"<link[^>]+>", re.I)
_REL_RE = re.compile(r"rel\s*=\s*[\"']([^\"']+)[\"']", re.I)
_HREF_RE = re.compile(r"href\s*=\s*[\"']([^\"']+)[\"']", re.I)
# Параллельные загрузки одной иконки сериализуем: не долбим чужой сайт
_FETCH_LOCK = asyncio.Lock()
def _cache_dir() -> Path:
path = Path(get_settings().favicons_path)
path.mkdir(parents=True, exist_ok=True)
return path
def _key(url: str) -> str:
return hashlib.sha1(url.encode()).hexdigest()
def is_public_host(host: str) -> bool:
"""Хост резолвится только в публичные адреса (SSRF-барьер).
is_global=False покрывает private/loopback/link-local/reserved/CGNAT —
в том числе 169.254.169.254 (метаданные облака).
"""
if not host:
return False
try:
infos = socket.getaddrinfo(host, None)
except (socket.gaierror, UnicodeError, OSError):
return False
if not infos:
return False
for info in infos:
raw = str(info[4][0])
# IPv6-адреса приходят с зоной (%eth0) — для разбора она лишняя
raw = raw.split("%", 1)[0]
try:
ip = ipaddress.ip_address(raw)
except ValueError:
return False
if not ip.is_global:
return False
return True
def _validated(url: str) -> bool:
"""URL безопасен для запроса: http(s), есть хост, хост публичный."""
if len(url) > MAX_SITE_URL:
return False
parts = urlsplit(url)
if parts.scheme not in ("http", "https") or not parts.netloc:
return False
return is_public_host(parts.hostname or "")
def _sniff_ext(data: bytes) -> str | None:
"""Расширение картинки по magic-bytes; None — не поддерживаемый формат."""
for magic, ext in _MAGIC:
if data.startswith(magic):
return ext
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return ".webp"
return None
async def _read_limited(resp: httpx.Response, limit: int) -> bytes | None:
"""Тело ответа не длиннее limit; None — превысило (иконка не бывает такой)."""
buf = bytearray()
async for chunk in resp.aiter_bytes():
buf += chunk
if len(buf) > limit:
return None
return bytes(buf)
async def _download(client: httpx.AsyncClient, url: str) -> tuple[bytes, str] | None:
"""Скачать картинку по уже проверенному url; None — не картинка/ошибка.
Возвращает байты и расширение из magic-bytes. Редиректы идём сами:
хост каждого хопа тоже проверяется.
"""
for _ in range(MAX_REDIRECTS + 1):
if not _validated(url):
return None
try:
async with client.stream("GET", url, follow_redirects=False) as resp:
if resp.status_code in (301, 302, 303, 307, 308):
location = resp.headers.get("location")
if not location:
return None
url = urljoin(url, location)
continue
if resp.status_code != 200:
return None
if "image/" not in resp.headers.get("content-type", "").lower():
return None # html/svg/что угодно — не favicon
data = await _read_limited(resp, MAX_BYTES)
except httpx.HTTPError:
return None
if data is None:
return None
ext = _sniff_ext(data)
if ext is None: # magic-bytes не сошлись: это не png/jpg/gif/webp/ico
return None
return data, ext
return None
async def _icon_hrefs(client: httpx.AsyncClient, site_url: str) -> list[str]:
"""Объявленные <link rel="icon" href=...> из <head> корня сайта."""
if not _validated(site_url):
return []
try:
async with client.stream("GET", site_url, follow_redirects=False) as resp:
if resp.status_code != 200:
return []
if "html" not in resp.headers.get("content-type", "").lower():
return []
raw = await _read_limited(resp, MAX_HTML_BYTES)
except httpx.HTTPError:
return []
if raw is None:
return []
out: list[str] = []
for tag in _TAG_RE.findall(raw.decode("utf-8", "ignore")):
m_rel = _REL_RE.search(tag)
m_href = _HREF_RE.search(tag)
if not m_rel or not m_href or "icon" not in m_rel.group(1).lower():
continue
resolved = urljoin(site_url, m_href.group(1))
if resolved not in out:
out.append(resolved)
return out
def _read_cache(key: str) -> tuple[bytes, str] | None:
"""Свежая иконка из кэша; None — нет или протухла."""
for entry in _cache_dir().glob(f"{key}.*"):
if entry.suffix == ".none":
continue
mime = _EXT_MIME.get(entry.suffix)
if mime is None:
continue
try:
if time.time() - entry.stat().st_mtime < CACHE_TTL:
return entry.read_bytes(), mime
except OSError:
continue
return None
def _has_fresh_negative(key: str) -> bool:
"""Негативный маркер ещё свежий — сайт повторно не дёргаем."""
marker = _cache_dir() / f"{key}.none"
try:
return marker.is_file() and time.time() - marker.stat().st_mtime < NEGATIVE_TTL
except OSError:
return False
def _write(key: str, data: bytes, ext: str) -> None:
_cleanup()
path = _cache_dir() / f"{key}{ext}"
tmp = path.with_suffix(f"{ext}.tmp")
tmp.write_bytes(data)
os.replace(tmp, path) # атомарно: читатель не увидит половину файла
def _write_negative(key: str) -> None:
_cleanup()
(_cache_dir() / f"{key}.none").write_bytes(b"")
def _cleanup() -> None:
"""Каталог не растёт бесконечно: при переполнении сносим самые старые файлы."""
directory = _cache_dir()
entries = sorted(directory.glob("*"), key=lambda p: p.stat().st_mtime)
while len(entries) >= MAX_CACHE_FILES:
oldest = entries.pop(0)
oldest.unlink(missing_ok=True)
async def fetch_favicon(site_url: str) -> tuple[bytes, str] | None:
"""Байты и mime favicon по site_url; None — не нашли (результат кэшируется)."""
key = _key(site_url)
cached = _read_cache(key)
if cached is not None or _has_fresh_negative(key):
return cached
async with _FETCH_LOCK:
cached = _read_cache(key)
if cached is not None or _has_fresh_negative(key):
return cached
timeout = httpx.Timeout(TOTAL_TIMEOUT, connect=CONNECT_TIMEOUT, read=READ_TIMEOUT)
headers = {"User-Agent": _UA, "Accept": "image/*;q=0.9,*/*;q=0.1"}
async with httpx.AsyncClient(timeout=timeout, headers=headers) as client:
# Сначала объявленная в HTML иконка (там png/webp, которых нет по
# /favicon.ico), затем общепринятые пути
candidates = await _icon_hrefs(client, site_url)
candidates += [
urljoin(site_url, "/favicon.ico"),
urljoin(site_url, "/favicon.png"),
urljoin(site_url, "/apple-touch-icon.png"),
]
for candidate in candidates:
found = await _download(client, candidate)
if found is not None:
data, ext = found
_write(key, data, ext)
return data, _EXT_MIME[ext]
_write_negative(key)
return None