Документация системы
Сеть независимых сайтов + один агрегатор продаж мест и заявок. Тестовый стенд: PB на 127.0.0.1:8097 (публично mailfly.ru), статика на бакете cdn.mailfly.ru
1. Обзор и законы архитектуры
Агрегатор (PocketBase) — продаёт места (sales), принимает заявки (leads), даёт кабинет. Не знает о товарах ничего: ни имён, ни цен, ни списков.
Сайт — чистая статика на S3, своей серверной части нет. Его «БД в рантайме» — два файла, выгруженные его CMS в бакет: витрина и owners-карта (
§5).
Законы (нарушение любого = ошибка проектирования):
- В страницы сайта не запекается ничего клиентского — только вечные id (§3). Смена владельца никогда не требует пересборки страниц
- Агрегатор не хранит данных товаров. Имя товара попадает к нему один раз — текстом внутри заявки (поле
data.item), справочно
- Ноль ветвлений «уникум/каталог»: единичная страница (Ремонт в Калуге) = каталог из одного товара с пакетом count:1. Один формат токена, один код резолва, один формат owners-файла и прайса, один рендер витрины и кабинета
Прайс = список пакетов:
[{count, perMonth:{"1":₽,"3":₽,…}}]. Покупка = выбор пакета:
sales.capacity = count,
amount = perMonth[срок] × срок. У «эксклюзива» в списке просто один пакет count:1.
capacity — что продано, лимит рекомендательный: соблюдает CMS сайта при выгрузке, при заявке не проверяется.
2. Путь заявки
1
Браузер на сайте: клик по кнопке → модалка (
nav-lead.js) →
POST https://mailfly.ru/api/leads/create с телом
{token, data}. Отправка — напрямую, при блокировке — через функцию (
§7).
data — произвольные поля формы, агрегатор их не разбирает. Туда же кладётся data.item — «Название — Регион» из витрины, чисто для чтения человеком.
↓
2
Сервер (route_leads.pb.js): первые 15 символов токена → оффер → сайт → адрес его owners-файла (sites.ownersUrl).
↓
3
Качает owners-файл с S3 (кэш в $app.store 60 сек), расшифровывает серверным ключом (
§6), берёт
owners[токен] → id владельца.
Владелец определяется в момент заявки. Смена владельца = перевыгрузка одного файла CMS-кой сайта.
↓
4
Проверка в sales: активная покупка владельца (оффер+регион, cancelled пусто, purchased + term×30дн не истёк). Нет — 404 no_active_owner.
↓
5
Создаётся lead {sale, data, status:'new'}. Ответ {ok:true, id}.
Пример запроса:
curl -X POST https://mailfly.ru/api/leads/create \
-H 'Content-Type: application/json' \
-d '{"token":"<offer15><region15><productId>",
"data":{"name":"Иван","phone":"+7…","comment":"…"}}'
Ошибки:
bad token (короче 31),
unknown offer,
site db unavailable (S3 не отдал файл),
unknown position (нет в owners),
no_active_owner.
3. Токен
offer id (15)region id (15)id товара в БД сайта (любая длина)
Одна строка без разделителей, сервер режет по позициям. Собирается в
nav-site.js при рендере кнопки:
openLead(p.offer + region.id + productId, title).
- offer/region — вечные id агрегатора
- productId — вечный id самого сайта (на реальном сайте — id записи его PB, на стенде — слаги вида
cement-m500)
Ничего клиентского: токен не меняется при смене владельца. Целиком является ключом в owners-файле.
4. Коллекции агрегатора и API rules
users (auth) — клиенты и партнёры: name, phone, role(client/partner), rate, discount, messengers(json)
sites — name, url,
ownersUrl (где лежит owners-файл). Rules: только суперюзер — клиент не должен видеть сеть (
§8)
offers — site, name. Rules: клиент видит только офферы из собственных покупок: @collection.sales.offer ?= id && @collection.sales.owner ?= @request.auth.id
regions — name. Rules: аналогично offers (только из своих покупок)
prices — offer, region (пусто = все регионы), policy = список пакетов (
§1). Rules: закрыто
sales — offer, region, owner, partner, partnerRate, term(мес), amount, purchased, cancelled, capacity. Rules: owner = @request.auth.id (list/view)
leads — sale(→sales), status(new/in_progress/done), data(json как есть). Rules: list/view sale.owner = @request.auth.id; update — тот же owner и @request.body.sale:changed = false && @request.body.data:changed = false (менять можно только status). Create — только серверный роут (минуя rules)
5. Файлы: что где лежит
Сервер — /opt/claude/trash/mailfly/pb_hooks/
route_leads.pb.js — POST /api/leads/create (резолв заявки, §2) + OPTIONS (CORS). Внутри — серверный OWNERS_KEY (§6)
route_cabinet.pb.js — GET /cabinet, оболочка кабинета (§8)
route_home.pb.js — GET /, главная стенда: все ссылки и демо-доступы
route_docs.pb.js — эта страница
Исходники фронта и генератора — /opt/claude/trash/mailfly/frontend/
nav-index.html — выбор сайта (стендовая страница)
nav-stroyka/-stroymaterialy/-arenda-kvartir.html — «сайты»: тонкие файлы, только конфиг SITE {title, tagline, icon, enc:'/…enc?v=…'} + подключение скриптов
nav-site.js — универсальный рендер витрины (один для всех сайтов): табы сети, чипсы регионов, карточки продавцов, регион в location.hash
nav-lead.js — модалка заявки + leadsSubmit (напрямую → функция), экран результата с явным закрытием
catalog-decrypt.js — расшифровка витрины в браузере (CATALOG_KEY зашит здесь)
transfer.js — клиент функции-прокси (§7)
export-sites.js — генератор выгрузки = имитация CMS всех трёх сайтов: держит «БД сайтов» (товары), берёт из агрегатора активные sales и имена клиентов, пишет 6 .enc + versions.json. Запуск: PB_URL=… PB_ADMIN_EMAIL=… PB_ADMIN_PASSWORD=… node export-sites.js, потом залить .enc в бакет и обновить ?v= в html
transfer-function/ — исходник Я.функции (go)
Бакет cdn.mailfly.ru (S3)
Те же html/js + данные:
<site>.enc — витрина: {cta, products:{pid:{offer,name,price?}}, regions:[{id,name,sellers:[{name,phone?,items:[pid]}]}]}. Товар хранится один раз, регионы ссылаются по id
<site>-owners.enc — карта {token: ownerId}. Страницы её не трогают, читает только сервер агрегатора
Клиентский SDK/доступ к PB у сайтов отсутствует полностью — их единственный «API» это эти два файла и POST /api/leads/create.
Функция
Yandex Cloud Functions, публичный вызов:
https://functions.yandexcloud.net/d4ecerg825inqkehj1hg. Исходник — frontend/transfer-function/. Кредов не содержит: ключ шифрования конверта захардкожен симметрично в transfer.js и функции (
§6).
6. Шифрование и ключи
Формат везде один: AES-256-GCM, base64(iv 12 байт ‖ шифртекст ‖ tag 16 байт). Совместим одновременно с PB $security.decrypt(строка, ключ32), Node crypto.createCipheriv('aes-256-gcm') и браузерным WebCrypto.
Три независимых ключа (значения в коде, тут не публикуются):
- CATALOG_KEY — витрины
<site>.enc. Живёт в catalog-decrypt.js и export-sites.js. По определению публичен (браузер расшифровывает) — защита от простого парсинга, не от целевого
- OWNERS_KEY — owners-файлы. Живёт в route_leads.pb.js и export-sites.js. Знает только сервер — браузеру файл недоступен по содержимому
- TRANSFER_KEY — конверт функции-прокси. Живёт в nav-lead.js (клиент) и в функции
В прод — сгенерировать новые все три и вынести из кода (
§11).
7. Обход блокировок: функция-прокси
Зачем: мобильные операторы режут по белым спискам — прямой запрос к домену агрегатора может не пройти, а functions.yandexcloud.net в списках есть.
Как шлёт клиент (
leadsSubmit в nav-lead.js):
- прямой
fetch на target с таймаутом 2500 мс
- не прошло →
transferCall(FUNC_URL, key, {target, method, path, contentType, body}): конверт шифруется TRANSFER_KEY, функция расшифровывает, делает запрос к target от себя, ответ возвращает тем же шифрованным конвертом
Функция — тупой прокси, агрегатор отличий не видит. Заголовок Authorization функция пока НЕ пробрасывает — для SPA-кабинета потребуется доработка (
§12).
8. Кабинет
GET /cabinet — сервер отдаёт статическую оболочку; все данные страница берёт из штатного PB API под токеном (auth-with-password, токен в localStorage cab_auth).
Вход один на всех: форма пробует авторизацию сначала как клиент (
users), потом как админ (
_superusers). Клиент видит только своё — режут API rules (
§4); админа rules не ограничивают — те же экраны показывают всё + имя владельца на карточках. Отдельного экрана заявок нет — всё здесь.
Показывает: места (sales: оффер · регион, пакет N позиций, срок, сумма, активна до/истекла/отменена) и заявки (leads: данные формы + смена статуса новая → в работе → завершена).
Легенда «один сайт»: клиент не должен знать про сеть. В UI ни слова про сеть/сайты; API rules (
§4) не дают перечислить чужое: sites 403, offers/regions — только из своих покупок.
9. S3, кэширование, версии
- Бакет = домен (
cdn.mailfly.ru), HTTPS-сертификат привязан к бакету напрямую (без CDN): yc storage bucket set-https
*.html, *.js, *-owners.enc — max-age=60, must-revalidate: правки видны за минуту, owners-файл сервер и так кэширует 60 с
<site>.enc (витрины) — max-age=31536000, immutable + версия в URL ?v=<hash>. Хэш считается от ДАННЫХ (не шифртекста), поэтому без изменений данных версия стабильна. Новая выгрузка данных → новый ?v в html сайта
- gzip: сам бакет-website не сжимает. Если понадобится — вешать Yandex CDN перед бакетом (даст gzip/brotli + edge-кэш), но у CDN-хоста есть риск вылета из белых списков операторов — проверить перед включением
10. Чеклист: подключить новый сайт
- В агрегаторе: запись в
sites (name, url, ownersUrl → куда сайт будет класть owners-файл), офферы в offers, прайсы-пакеты в prices. Регионы общие
- В CMS сайта: у товара поле «владелец» = id клиента из агрегатора (указывается при размещении). Товар заводится ОДИН раз, регионы размещения — ссылками
- Экспорт из CMS двух файлов (формат — §5, шифрование — §6): витрина + owners-карта
{offerId+regionId+productId: ownerId}. Выгрузка в бакет при каждом изменении данных
- На страницах сайта: подключить transfer.js + nav-lead.js (или свой аналог формы), кнопка вызывает
openLead(offer+region+productId, 'подпись'). Если витрина не шифруется — catalog-decrypt.js не нужен
- Продажа мест: запись в
sales (пакет из прайса, capacity=count). Всё — заявки маршрутизируются
Ничего специфичного «под тип» сайта не настраивается — уникум это каталог из одного товара с пакетом count:1.
11. Чеклист: перенос в прод
- Сгенерировать новые CATALOG/OWNERS/TRANSFER ключи, вынести из кода (env или PB settings), старые считать скомпрометированными
- Свой домен агрегатора + бакеты сайтов; HTTPS к бакетам напрямую (§9)
- Задеплоить функцию в своё облако, обновить URL в nav-lead.js
- /api/leads/create: rate-limit + honeypot-поле против спама (сейчас нет)
- Уведомления владельцу о новой заявке (email/telegram; хук ext_ на onRecordAfterCreateSuccess leads) — не реализовано
- Смена паролей клиентов: сейчас тестовые (кабинет), включить сброс пароля по email
- Партнёрка: поля partner/partnerRate в sales есть, начисления не реализованы
- Мониторинг ответов no_active_owner / site db unavailable — сигнал рассинхрона выгрузки или падения S3
- Продление/оплата sales — вручную или биллинг, не реализовано
12. План: SPA-кабинет на S3
Кабинет уже фактически SPA — SSR-роут отдаёт только оболочку. Перенос:
- Сохранить HTML кабинета файлом в бакет (cabinet.html)
- Напрямую заработает сразу: PB отдаёт API с
Access-Control-Allow-Origin: *
- Для обхода блокировок — обернуть вызовы API в паттерн leadsSubmit (§7) и доработать функцию+transfer.js: проброс заголовка
Authorization
- SSE/realtime через функцию не работает — кабинет его не использует
- Удалить SSR-роут /cabinet