Перейти к основному содержимому

Маршрутизация веб-сайтов и мульти-сайт

Теперь одна церковь может обслуживать более одного отдельного веб-сайта, и каждый из них может жить либо на субдомене *.b1.church, либо на полностью пользовательском, принадлежащем церкви домене. На этой странице описан слой маршрутизации, который находится под конструктором: как входящий запрос разрешается в церковь и в конкретный сайт, модель данных мульти-сайта (сигнальное значение siteId, которое сохраняет неизменным рендеринг каждого уже существующего сайта) и граница пользовательских доменов — самоуправляемый прокси Caddy на EC2, который завершает TLS и переписывает домен каждой церкви на её вышестоящий сервер *.b1.church. О том, что именно рендерится после того, как запрос разрешён — дерево страница/раздел/элемент — см. Конструктор веб-сайтов.

Обзор

   grace.b1.church              www.gracechurch.org  (custom domain)
(b1.church subdomain) │
│ ▼
│ ┌──────────────────────────────────────────┐
│ │ Caddy edge — EC2 3.23.251.61 │
│ │ (proxy.b1.church) │
│ │ • terminates TLS (per-domain LE cert) │
│ │ • rewrites Host → {sub}.b1.church │
│ │ • reverse-proxies to B1App │
│ └────────────────────┬─────────────────────┘
│ Host = {sub}.b1.church
▼ ▼
┌────────────────────────────────────────────────────────────┐
│ B1App src/middleware.ts │
│ • always: delete any client-supplied x-site (anti-spoof) │
│ • internal *.b1.church Host ⇒ domains lookup stays inert │
│ • raw custom Host (bypassing Caddy) ⇒ lookup → set x-site │
└───────────────────────────┬────────────────────────────────┘
▼ next.config.mjs → host first-label → /[sdSlug]/…
┌─────────────────────────────────────────────────┐
│ [sdSlug] · ConfigHelper.load(sdSlug) │
│ GET /membership/churches/lookup/?subDomain=… │
│ → { id, name, subDomain, siteId? } │
│ threads ?siteId= into every content call: │
│ /content/pages/:id/tree · /globalStyles · │
│ /blocks/public/footer · /links · sitemap │
└─────────────────────────────────────────────────┘

domain save/delete (B1Admin Settings→Domains → POST /membership/domains)
└─ best-effort CaddyHelper.updateCaddy() (wrapped, non-fatal, 10s timeout)
Caddy reads the domains table itself via two anonymous endpoints:
GET /membership/domains/authorize — on-demand-TLS `ask` (200 known / 404 unknown)
GET /membership/domains/hostmap — host→{sub}.b1.church map (5-min refresh)

Три правила действуют в этом слое:

  1. Сигнальное значение сохраняет обратную совместимость. siteId = '' — это основной сайт. Каждая страница, блок, ссылка, глобальный стиль и строка домена, существовавшие до этой функции, несут '' и рендерятся ровно так же, как и раньше. Второй веб-сайт — это просто набор строк с непустым siteId, а любая конечная точка контента, вызванная без ?siteId=, возвращает основной сайт — байт в байт тот же запрос, что и раньше.
  2. Разрешение основано на метке хоста и сходится к общему пути. Субдомен *.b1.church маршрутизируется напрямую по метке хоста; пользовательский домен переписывается в свою метку {sub}.b1.church на границе Caddy до того, как его увидит B1App (с поиском в базе данных из middleware, ставящим заголовок x-site как резерв для любого сырого пользовательского Host). Обе ветки попадают на один и тот же маршрут [sdSlug] и один и тот же вызов churches/lookup, поэтому нижестоящий рендеринг идентичен.
  3. Граница Caddy не хранит состояния поверх единого источника истины. Пользовательские домены завершаются на самоуправляемом прокси Caddy на EC2, который переписывает каждый домен на вышестоящий сервер {sub}.b1.church. Сохранение домена вызывает единый лучший-по-возможности вызов CaddyHelper.updateCaddy(), а Caddy также напрямую читает таблицу domains (конечные точки authorize и hostmap ниже). Таблица является авторитетной — недоступный Caddy никогда не может сорвать сохранение.

Разрешение сайта

Субдомены *.b1.church

B1App/next.config.mjs переписывает входящие запросы по хосту. Правило хоста с шаблоном (?<subdomain>.*?)\..* захватывает первую метку хоста и переписывает / и /:path* в /{subdomain} — сегмент App Router [sdSlug]. Так grace.b1.church/about становится /grace/about.

Внутри src/app/[sdSlug]/ ConfigHelper.load(sdSlug) (src/helpers/ConfigHelper.ts) вызывает GET /membership/churches/lookup/?subDomain={sdSlug}. Ответ ChurchController.getBySubDomain теперь имеет две ветви:

Совпадение slugОтветЗначение
churches.subDomain{ id, name, subDomain }Основной сайт этой церкви
sites.subDomain{ id, name, subDomain, siteId }Дополнительный сайт — контроллер откатывается к sites, разрешает владеющую церковь и отражает запрошенный slug плюс дополнительный siteId

Именно этот дополнительный siteId — единственное, что отличает запрос к дополнительному сайту от запроса к основному; всё остальное в конвейере общее.

Пользовательские домены

Домен, принадлежащий церкви, завершается на границе Caddy (подробно ниже), которая переписывает заголовок Host в {sub}.b1.church сайта перед проксированием к B1App. Так что на обычном пути B1App получает внутренний хост *.b1.church и разрешает его по метке хоста ровно как обычный субдомен — поиск в базе данных из middleware никогда не срабатывает. src/middleware.ts всё же выполняется при каждом запросе, но с одной постоянной задачей и одним резервным вариантом:

  1. Всегда — он удаляет любой заголовок x-site, предоставленный клиентом. Этот заголовок — уязвимый для подмены вход перезаписи и является доверенным, только когда его устанавливает само middleware; удаление его — реальная задача middleware за Caddy.
  2. Резерв, только для не-внутреннего Host — для сырого Host пользовательского домена, который достигает B1App без переписывания Caddy, оно вызывает GET /membership/domains/public/lookup/{host}, и если это возвращает subDomain, устанавливает x-site: {subDomain}.b1.church. За Caddy эта ветвь неактивна, потому что Host уже *.b1.church.

Внутренние хосты — localhost, b1.church и суффиксы .b1.church, .localtest.me, .localhost, .up.railway.app, .vercel.app — полностью пропускают поиск (они уже разрешены переписыванием по метке хоста, либо это хосты предпросмотра/развёртывания).

Сам поиск (DomainRepo.loadByName) выполняет левое соединение domains → churches и domains → sites и возвращает COALESCE(NULLIF(sites.subDomain,''), churches.subDomain) — субдомен назначенного дополнительного сайта, если домен указывает на него, иначе субдомен церкви. Сначала совпадает точный хост; если этот хост начинался с www. и не найден, повторяется один раз против голого апекса.

Обратно в next.config.mjs правила переписывания x-site размещены раньше общих правил хоста, поэтому они побеждают. x-site: grace.b1.church → первая метка grace[sdSlug] = grace, и оттуда разрешение идентично пути субдомена (тот же churches/lookup, тот же siteId).

Информация

Заголовок x-site недоверен снаружи. Middleware безусловно удаляет любой входящий x-site, прежде чем опционально устанавливает свой собственный, а правила переписывания видят только значение, установленное middleware — клиент не может принудительно попасть на контент другой церкви, отправив заголовок.

Две операционные детали middleware:

  • Кэш. Результат для каждого хоста (совпадение или подтверждённое отсутствие — никогда сетевая ошибка) кэшируется на 10 минут в памяти, в Map, на каждый экземпляр serverless.
  • Сопоставитель маршрутов. Сопоставитель намеренно включает обратно /sitemap.xml, /robots.txt и /manifest.webmanifest. Его первый шаблон исключает пути с точками, которые иначе отбросили бы эти файлы; они добавлены обратно, чтобы SEO/PWA-файлы пользовательского домена для каждой церкви также получали заголовок x-site.

Протягивание siteId

ConfigHelper сохраняет разрешённый siteId в своём ConfigurationInterface на каждый запрос (мемоизирован через React cache()) и добавляет ?siteId= к вызовам контента, которые делает он сам и компоненты страницы — условно: пустой siteId (субдомен основной церкви) полностью опускает параметр. Протянутые конечные точки — это дерево страниц (/content/pages/:id/tree), публичный список страниц, используемый картой сайта (/content/pages/public/:id), глобальные стили (/content/globalStyles/church/:id), навигационные ссылки (/content/links/church/:id) и отдельный блок подвала (/content/blocks/public/footer/:id). На обычном пути рендеринга подвал приходит внутри дерева страниц (разделы, помеченные zone: "siteFooter"), уже полученные с siteId, поэтому нет пробела с неограниченным по сайту подвалом.

Портал участников (мобильный режим B1App) намеренно находится вне этого: loadChurchAppearance.ts разрешает церковь через churches/lookup, но читает /settings/public/{id} на уровне церкви и никогда не протягивает siteId — портал остаётся общецерковным в v1 (см. ниже).

Несколько веб-сайтов на церковь

Модель данных

Новая таблица membership.sites намеренно крошечная:

СтолбецТипПримечания
idchar(11) PK
churchIdchar(11)Владеющая церковь
namevarchar(255)Отображаемое имя (например, «Español», «Youth»)
subDomainvarchar(45)Уникальный индекс — глобальное пространство имён (ниже)

Область видимости сайта — это затем единственный столбец без null-значений, добавленный в таблицы контента и доменов:

Таблица (модуль)СтолбецЧто означает ''
domains (membership)siteId char(11) NOT NULL DEFAULT ''Домен обслуживает основной сайт
pages, links, globalStyles, blocks (content)siteId char(11) NOT NULL DEFAULT ''Основной сайт — а для blocks '' дополнительно означает общий для всех сайтов

Две миграции добавляют всё это (tools/migrations/membership/2026-07-02_sites.ts, tools/migrations/content/2026-07-02_site_id.ts). Поскольку столбец по умолчанию равен '', каждая существующая строка сохраняет сегодняшнее поведение без пересчёта данных.

Глобальное пространство имён субдоменов. sites.subDomain разделяет одно пространство имён с churches.subDomain — субдомен сайта никогда не может столкнуться с субдоменом церкви или другого сайта. Это обеспечивается на обоих путях сохранения: SiteController.save отклоняет slug, совпадающий либо с churches, либо с sites, а ChurchController.validateSave делает то же самое в обратную сторону. Уникальный индекс на sites.subDomain подкрепляет это на уровне базы данных.

Уникальность страниц расширена с (churchId, url) до (churchId, siteId, url), так что два сайта одной церкви могут каждый владеть собственной /about.

Контент по сайту, с резервными вариантами

Каждая конечная точка списка/дерева контента с областью видимости сайта принимает опциональный ?siteId= (отсутствие ⇒ '' = основной): дерево/список/публичный список страниц, список блоков / по типу / подвал, ссылки (анонимные / отфильтрованные / все) и глобальные стили. Разделы и элементы не имеют области видимости напрямую — они наследуют её через родительскую страницу или блок.

Две цепочки разрешения выполняют интересную работу:

  • Глобальные стили — сайт → основной → по умолчанию. GlobalStyleRepo.loadForChurch(churchId, siteId) возвращает собственную строку сайта; если у дополнительного сайта её нет, возвращается основная ('') строка как есть (сохраняя id/siteId основной, которые клиент использует для copy-on-write); если основной тоже нет, GlobalStyleController возвращает жёстко заданную палитру/шрифты по умолчанию.
  • Блок подвала — специфичный для сайта побеждает, общий служит резервом. BlockRepo.loadByBlockType(churchId, "footerBlock", siteId) возвращает как общие (''), так и специфичные для сайта строки; резолвер выбирает собственный подвал сайта, если он есть, иначе общий. Та же логика выполняется как в TreeHelper.insertBlocks (дерево страниц), так и в отдельной конечной точке /content/blocks/public/footer/:churchId.

Каскад удаления сайта

SiteController.delete (доступ ограничен разрешением Settings→Edit модуля membership) сносит дополнительный сайт в три шага:

  1. ContentModuleGateway.deleteSiteContent(churchId, siteId) каскадно удаляет весь контент, которым владеет сайт: его страницы → их разделы, элементы, pageHistory и posts; его собственные блоки → их разделы, элементы и pageHistory; его ссылки и globalStyles. Защита отказывается выполняться для '' — основной/общий сигнал никогда не каскадируется.
  2. DomainRepo.clearSiteId переназначает домены сайта обратно на основной (siteId → ''), а не удаляет их, так что пользовательский домен переживает удаление сайта.
  3. Строка sites удаляется, и маршруты Caddy пересинхронизируются (по возможности).

Поверхность B1Admin

ВозможностьГдеМеханизм
Переключатель сайтаuseSiteSelection + SiteSwitcher (пусто = «Main Website»)Читает URL-параметр ?site= и протягивает его как ?siteId= в вызовы ContentApi. Присутствует на трёх областях списка Site — Pages, Blocks, Appearance — но не в редакторах страниц/блоков, которые несут siteId в самой записи
Создание/удаление сайтовSitesDialog, открывается из пункта «Manage websites…» переключателяPOST /membership/sites / DELETE /membership/sites/:id (имя + subDomain). Доступ ограничен разрешением Settings→Edit модуля membership (Permissions.settings.edit на сервере; Permissions.membershipApi.settings.edit в B1Admin). Только создание/удаление — в v1 нет интерфейса переименования
Назначение сайта по доменуDomainSettingsEdit в разделе Settings→DomainsВыпадающий список сайта в каждой строке отправляет siteId для домена в /membership/domains. Столбец скрывается, если API не возвращает сайтов (более старый бэкенд)
Стили copy-on-writeStylesManager.prepareForSaveКогда siteId загруженной строки глобального стиля не совпадает с выбранным сайтом (то есть API вернул унаследованный основной как резерв), это отбрасывает id основного и проставляет текущий siteId, вынуждая вставку новой специфичной для сайта строки вместо перезаписи основной. Та же логика fork-при-несовпадении применяется к блоку подвала сайта
Информация

Что остаётся общецерковным в v1 (намеренный выбор области видимости, а не ограничение модели данных): блогBlogPage нет переключателя, и он загружает /posts без siteId), виджеты сайта (баннер объявлений + лаунчер), редиректы, логотип / GA4 / настройки церкви и портал участников (мобильный режим B1App). Обратите внимание, что это не «весь раздел Appearance» — глобальные стили дополнительного сайта (палитра, шрифты, типографика, отступы, навигация, пользовательский CSS) являются посайтовыми через путь copy-on-write выше; общецерковными остаются только подпанели баннера/лаунчера/редиректов/логотипа страницы Appearance.

Пользовательские домены: граница Caddy (план со статической конфигурацией)

Информация

Направление пересмотрено 2026-07-02. Более ранний план перенести хостинг пользовательских доменов на управляемые Vercel домены был отменён, и весь код регистрации доменов Vercel (VercelHelper, его переменные окружения vercelToken/vercelProjectId/vercelTeamId, параметры SSM и записи здоровья) был удалён из Api. Самоуправляемый прокси Caddy на EC2 остаётся постоянной границей пользовательских доменов. Единственная оставшаяся работа — внутренняя: замена runtime-конфигурации Caddy через admin API на статическую конфигурацию, переживающую перезапуски.

Граница

Каждый пользовательский домен церкви указывает DNS на один EC2-хост — 3.23.251.61, также доступный как proxy.b1.church. Экран Settings→Domains в B1Admin инструктирует церкви добавить апекс A → 3.23.251.61 либо CNAME → proxy.b1.church. Caddy завершает TLS сертификатом Let's Encrypt для каждого домена, переписывает заголовок Host на вышестоящий сервер {sub}.b1.church домена и реверс-проксирует к B1App — который затем маршрутизирует его по метке хоста как любой обычный субдомен (см. Пользовательские домены выше).

Сопоставление вышестоящих серверов берётся из DomainRepo.loadPairs, чей набор для соединения делает COALESCE субдомена назначенного сайта, так что домен проксируется к правильному дополнительному сайту, откатываясь к основному сайту церкви:

CONCAT(COALESCE(NULLIF(s.subDomain,''), c.subDomain), '.b1.church:443')  AS dial
WHERE d.domainName NOT LIKE '%www.%'

Строки www.* исключены из карты; Caddy обслуживает www.{host} через редирект 302 на апекс вместо этого.

Две анонимные конечные точки питают границу

DomainController предоставляет две неаутентифицированные, только для чтения конечные точки, которые хост потребляет напрямую — анонимные по необходимости, поскольку граница запрашивает их до того, как появляется какой-либо контекст церкви:

Конечная точкаВозвращаетРоль
GET /membership/domains/authorize?domain=200, если домен — или, для промаха www., его голый апекс — существует в domains; иначе 404 (включая пустой domain)ask Caddy для on-demand TLS: контроль злоупотреблений, решающий, выдавать ли сертификат для входящего SNI
GET /membership/domains/hostmaptext/plain, одна отсортированная строка {domain} {sub}.b1.church на маршрутизируемый доменФайл карты хост→вышестоящий сервер, который хост обновляет по таймеру

authorize переиспользует DomainRepo.loadByName (точный хост, затем одна повторная попытка www.→апекс); hostmap переиспользует loadPairs — поэтому он учитывает сайты и исключает www.*, идентично маршрутам прокси — и просто отбрасывает суффикс :443.

Сохранение/удаление домена — один лучший-по-возможности пуш

DomainController.save записывает строки domains, а затем делает единственный лучший-по-возможности вызов CaddyHelper.updateCaddy(), обёрнутый в try/catch, который логирует (console.error) и проглатывает ошибку; delete делает то же самое (что также исправило прежнюю ошибку с устаревшим маршрутом при удалении), как и удаление дополнительного сайта (SiteController.delete). Сам updateCaddy ограничен тайм-аутом Axios в 10 секунд, так что недоступный или остановленный Caddy никогда не может вызвать 500 при сохранении домена — таблица domains является источником истины.

Текущее состояние — статическая конфигурация, без состояния на этапе выполнения

Хост (Windows EC2 за постоянным Elastic IP) запускает Caddy со статическим Caddyfile: on-demand TLS, чей ask указывает на /membership/domains/authorize, плюс файл карты хост→вышестоящий сервер, обновляемый каждые 5 минут из /membership/domains/hostmap запланированной задачей, завершающейся плавным caddy reload. Конфигурация переживает перезапуски с нулевым состоянием времени выполнения — никакого танца с повторной инициализацией — и неизвестный SNI получает отказ на уровне TLS (сертификат не выдаётся для хоста, который отклоняет authorize), в то время как авторизованный, но ещё не сопоставленный хост (совсем новый домен внутри окна синхронизации) получает чистую 404. Новые домены становятся маршрутизируемыми в течение ~5 минут после сохранения; их сертификаты выпускаются при первом обращении. Сборка/настройка, эксплуатация и проверенные на практике подводные камни: Прокси Caddy для пользовательских доменов.

Устаревший runtime-пуш — путь отката, ожидающий удаления

CaddyHelper (модуль membership) всё ещё может управлять Caddy через его admin API на caddyHost:caddyPort (SSM caddyHost/caddyPort; не делает ничего, если не задано; отображается в группе интеграций ServerHealthController): updateCaddy() выполняет PATCH полного массива маршрутов, а initializeCaddy() + конечные точки GET /membership/domains/caddy/init / GET /membership/domains/caddy пересобирают сервер с runtime-конфигурацией с нуля. Конфигурация этого режима жила только в памяти Caddy — именно эту «амнезию при перезапуске» заменила данная архитектура. Механизм остаётся исключительно как путь отката и запланирован к удалению, как только статический хост докажет свою стабильность; лучший-по-возможности пуш updateCaddy() при сохранении/удалении домена — безобидная холостая операция против статического хоста (его admin API доступен только с localhost).

Связанные страницы