Newer
Older
gnexus-tasks / backend / app / services / favicon.py
"""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