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

Приложение с базой данных без бэкенда: собираем Layero Inbox

Бета-версия

Data API работает в бета-версии и открыт пока не всем. Как получить доступ и чего ждать от страниц, написано в «Что такое Data API».

Пошаговый разбор приложения на Layero: Postgres, Data API, вход, файлы и уведомления. Около 30 минут.

Layero Inbox: лендинг с формой обратной связи

Layero Inbox — виджет для сайта и кабинет к нему. Посетитель сайта сообщает о проблеме и прикладывает снимок экрана, оставляет заявку или записывается на свободное время. Владелец сайта видит всё во входящих, разбирает сообщения вместе с командой и получает уведомления в Telegram.

У приложения нет своего сервера: только Postgres, Data API поверх него и три статические страницы. Ниже — как это устроено и как повторить это в своём продукте.

Код: github.com/LayeroInfra/layero-inbox · Демо: inbox.layero.app

Что вы получите​

  • Базу с правами на уровне строк. Владелец сайта видит только свои сайты, пользователь из команды — только сайты, куда его пригласили, посетитель — ничего.
  • Приём форм от посетителей без входа — через функции базы, которые проверяют всё, что приходит из браузера.
  • Приватные файлы. Снимки экрана лежат в закрытом бакете и открываются по подписанным ссылкам.
  • Вход в кабинет по почте и паролю или через Яндекс ID.
  • HTTP-запросы из SQL — уведомления в Telegram отправляет сама база.
  • Проверку прав одной командой — от имени посетителя и каждого пользователя.

Что понадобится​

  • аккаунт Layero и организация с общей базой (тариф Pro) или с выделенной базой;
  • Node 20+;
  • для уведомлений — бот от @BotFather (необязательно);
  • для входа через Яндекс — приложение в Яндекс OAuth (необязательно).

Кто есть кто​

Кто этоРоль в базе
Посетитель сайтаЧеловек на сайте, где стоит виджет; в систему не входитanon
ПользовательВошёл в кабинет по почте или через Яндексauthenticated
Владелец сайтаПользователь, который добавил сайт в кабинетauthenticated + sites.owner_id
Команда сайтаПользователи, которых владелец пригласилauthenticated + site_members

Как это устроено​

сайт с виджетом ─iframe─▶ виджет ──RPC──▶ Data API ──▶ Postgres
▲ │ RLS, функции, файлы
владелец, команда ──▶ кабинет ──REST/auth──┘ └── net.http_post ──▶ Telegram

Браузер обращается к Data API напрямую, с публичным ключом. Ключ сам по себе прав не даёт: что можно посетителю и каждому пользователю, решают политики RLS и функции в Postgres. Секретный ключ приложению не нужен.

Каждый шаг ниже можно сделать в панели Layero или в терминале — результат одинаковый. Всё сразу делает scripts/setup.sh (шаг 8).

Шаг 1. Создайте базу и включите Data API​

«Базы данных» → «Создать базу». Одна общая база входит в тариф Pro. Откройте базу и включите три сервиса.

Обзор базы: сервисы Data API, Авторизация, Файлы

  • Data API — таблицы и функции базы по HTTP; совместим с supabase-js.
  • Авторизация — регистрация и вход. Появляются схема auth и функция auth.uid(), на которой построены все политики.
  • Файлы — хранилище с бакетами.

Сразу заведите бакет для снимков экрана.

«Файлы» → «Добавить бакет».

Новый бакет screenshots

  1. «Публичный» выключен: снимки открываются только по подписанным ссылкам.
  2. До 5 МБ и только JPEG или PNG — остальное бакет не примет.
warning

Включайте авторизацию до миграций: политики ссылаются на auth.uid(), и без авторизации миграция упадёт на первой же политике.

Шаг 2. Опишите таблицы и закройте их политиками​

Четыре таблицы: сайты, команда сайта, приглашения и сообщения. Главное правило — посетителю таблицы закрыты целиком:

alter table public.reports enable row level security;
revoke all on public.sites, public.site_members, public.invites, public.reports from anon;

Команда сайта читает его сообщения и может менять только статус — это право на уровне колонки:

revoke insert, update on public.reports from authenticated;
grant update (status) on public.reports to authenticated;

create policy reports_read on public.reports for select to authenticated
using (inbox_private.is_member(site_id));

Проверку, состоит ли пользователь в команде сайта, вынесите в функцию с security definer. Если политика sites обращается к site_members напрямую, а политика site_members — к sites, Postgres уйдёт в бесконечную рекурсию:

create function inbox_private.is_member(p_site uuid) returns boolean
language sql stable security definer set search_path = public as $$
select exists (select 1 from public.site_members m
where m.site_id = p_site and m.user_id = auth.uid());
$$;

SQL-редактор: вставьте текст миграции целиком и выполните его. Файлы выполняйте по порядку, от 0001 до 0004.

SQL-редактор с миграцией

  1. «Выполнить» или ⌘/Ctrl + Enter. Роль — «Владелец»: она создаёт таблицы, функции и политики.
  2. Текст миграции.

Что получилось, видно на экране «Политики»:

Политики таблицы reports

  1. RLS включён: строку, к которой не подходит ни одна политика, не увидит никто.
  2. Три политики: читать и менять статус может команда сайта, удалять — только владелец.

Полная схема — 0001_schema.sql.

Шаг 3. Примите форму от посетителя через функцию​

Посетитель не входит в систему, но должен отправить форму. Писать в таблицу ему нельзя — иначе он запишет что угодно и куда угодно. Поэтому форму принимает функция в схеме api, которую Data API публикует как POST /rest/v1/rpc/submit_form:

create function api.submit_form(site_key text, form text, message text default null, …)
returns jsonb language plpgsql security definer set search_path = public as $$
begin
-- ключ сайта существует, форма включена, не больше 30 сообщений в минуту,
-- у заявки есть контакт и согласие, слот записи свободен…
insert into public.reports (…) values (…);
return jsonb_build_object('id', v_id, 'upload_path', v_path);
end $$;

grant execute on function api.submit_form(…) to anon, authenticated;

Data API → «Методы»: что и кому база отдаёт по HTTP.

Data API → Методы

  1. Функции кабинета — только для вошедших пользователей. Права внутри проверяет функция.
  2. site_form и submit_form доступны любому посетителю — так работает виджет.

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

create unique index reports_booking_slot on public.reports (site_id, booking_at)
where kind = 'booking' and status <> 'rejected';

Так это выглядит для посетителя. Слот 12:00 в среду уже занят, поэтому в списке его нет.

Форма записи: свободные слоты

Все функции — 0002_forms.sql.

Шаг 4. Разрешите загрузку файла ровно в одно место​

Снимок экрана загружает посетитель, но только по пути, который выдала submit_form, в течение 10 минут и только один раз:

create policy screenshots_visitor_upload on storage.objects
for insert to anon, authenticated
with check (bucket_id = 'screenshots'
and inbox_private.screenshot_upload_allowed(name));

Форма проблемы со снимком экрана

Виджет делает снимок до того, как откроет форму, поэтому сам на него не попадает.

В панели: «Файлы» → «Политики».

Политики бакета screenshots

  1. Команда сайта открывает снимки, владелец может их удалить.
  2. Посетитель загружает свой снимок и получает подписанную ссылку на минуту, чтобы Telegram мог скачать фото.

В кабинете снимки открываются по подписанным ссылкам:

const { data } = await db.storage.from("screenshots").createSignedUrl(path, 600);

Политики — 0003_storage.sql.

Шаг 5. Отправьте уведомление прямо из базы​

Сохраните токен бота как секрет базы.

Data API → «Доступ» → «Секреты» → «Добавить секрет».

Новый секрет TELEGRAM_BOT_TOKEN

  1. Имя секрета. В SQL оно пишется как {{secrets.TELEGRAM_BOT_TOKEN}}.
  2. Токен от @BotFather. После сохранения его не покажет ни панель, ни журнал.

Значение подставляется в запрос только в момент отправки:

create function inbox_private.tg_send_text(p_chat text, p_text text) returns void
language sql as $$
select net.http_post(
url := 'https://api.telegram.org/bot{{secrets.TELEGRAM_BOT_TOKEN}}/sendMessage',
body := jsonb_build_object('chat_id', p_chat, 'text', p_text));
$$;

net.http_post ставит запрос в очередь и отправляет после коммита: если вставка откатится, уведомление не уйдёт. Триггер на reports вызывает эту функцию для каждого нового сообщения. Снимок уходит фотографией: Telegram скачивает его сам по подписанной ссылке.

Подключение без номера чата. Входящих вебхуков у Data API нет, поэтому кабинет открывает t.me/<бот>?start=<код> и, пока владелец сайта на странице, раз в две секунды спрашивает базу, не пришёл ли этот код в getUpdates.

Telegram в настройках сайта

  1. Подключённый чат и выбор сообщений, которые в него приходят.
  2. Одна кнопка: откроется бот, нажмите «Старт» — и чат подключён.

Подробности — в notifications.md.

Шаг 6. Соберите кабинет на supabase-js​

Data API совместим с supabase-js, поэтому кабинет — обычный клиент.

Data API → «Подключение».

Data API → Подключение

  1. Адрес и публичный ключ. Если проект подключён к базе, Layero сам подставит их в сборку.
  2. Клиент — обычный supabase-js.
import { createClient } from "@supabase/supabase-js";

const db = createClient(import.meta.env.VITE_LAYERO_DATA_URL,
import.meta.env.VITE_LAYERO_DATA_KEY);

const { data } = await db.from("reports").select("*")
.eq("site_id", siteId).order("created_at", { ascending: false });

Фильтр site_id здесь для удобства, а не для защиты: чужих строк запрос не вернёт, даже если фильтр убрать.

Входящие в кабинете

  1. Статусы. Команда сайта может менять только статус сообщения.
  2. Снимок экрана из приватного бакета, по подписанной ссылке на 10 минут.

Вход через Яндекс. Почта с паролем работает сразу. Яндекс ID подключается только в панели: «Авторизация» → «Способы входа» → «Яндекс».

Настройка входа через Яндекс ID

  1. Этот адрес впишите в приложение на oauth.yandex.ru, а Client ID и секрет оттуда — сюда.
  2. Адреса, на которые можно вернуть пользователя после входа.

В клиенте — одна строка:

await db.auth.signInWithOAuth({ provider: "yandex", options: { redirectTo: APP_URL } });

Имя отправителя в письмах для входа:

npx layero@latest data auth sender "Layero Inbox" --db inbox

Шаг 7. Встройте виджет через iframe​

Data API по умолчанию принимает запросы только с адресов ваших проектов. Для кабинета это то, что нужно. Но виджет работает на чужих сайтах, поэтому embed.js к базе не обращается: он рисует кнопку, делает снимок экрана и открывает форму в iframe с адреса виджета. Запросы к базе уходят оттуда.

Data API → «Доступ» → «С каких сайтов доступен Data API».

Разрешённые адреса Data API

«Любой сайт» выключен: чужие сайты в базу не обращаются, для них виджет открывает iframe. Адреса виджета и кабинета, а также окружений их веток Layero добавляет сам.

Ключ сайта st_… кабинет показывает, когда владелец добавляет сайт. На сайт ставится одна строка, кнопки — по желанию:

<script src="https://inbox-widget.layero.app/embed.js" data-site="st_…" async></script>
<button data-inbox="booking">Записаться</button>

Так публичный ключ и логика формы не попадают в контекст чужой страницы.

Шаг 8. Выложите и проверьте права​

Шаги 1–7 и выкладку двух проектов делает один скрипт:

LAYERO_ORG=my-team APP_ADDRESS=my-inbox ./scripts/setup.sh

В нашем прогоне база, схема и секреты заняли меньше минуты, сборка виджета — две, лендинга с кабинетом — пять. Итого около восьми минут.

Лендинг тоже принимает формы; ключ сайта для них задаётся переменной проекта.

Проект → «Переменные окружения».

Переменные проекта

  1. Свои переменные: адрес виджета, имя бота, ключ сайта. После изменения проект нужно пересобрать.
  2. Переменные с пометкой «ставит платформа» появляются сами, когда проект подключён к базе: адрес базы, адрес Data API и публичный ключ.

Остаётся проверить права. layero data probe выполняет запрос через настоящий шлюз от имени посетителя или конкретного пользователя и откатывает запись:

OWNER_EMAIL=… OTHER_EMAIL=… MEMBER_EMAIL=… ./scripts/check-access.sh
Посетитель сайта (публичный ключ)
✓ не читает сообщения 401
✓ не пишет в таблицу напрямую 401
✓ не видит список сайтов 401
Владелец сайта
✓ видит свой сайт 200:1
✓ меняет статус 200:1
✓ не меняет текст сообщения 403
Пользователь из другой команды
✓ не видит чужой сайт 200:0
✓ не видит чужое сообщение по id 200:0
✓ не меняет чужое сообщение 200:0
✓ не удаляет чужой сайт 200:0
✓ не добавляет себя в чужую команду 403
✓ не приглашает себя в чужую команду 403
✓ не создаёт сайт от имени владельца 403
✓ не видит снимки чужого сайта 200:0
Пользователь из команды сайта
✓ видит сайт 200:1
✓ меняет статус 200:1
✓ не удаляет сообщение — только владелец 200:0
✓ не меняет настройки сайта 200:0

Итог: 18 ✓, 0 ✗

200:0 — запрос прошёл, но RLS скрыл все строки: для этого пользователя их нет. 401 — посетителю таблицы закрыты целиком. 403 — у роли нет права на действие.

Подводные камни​

Что случилосьПочемуКак обойти
Миграция падает на auth.uid()Авторизация не включенаВключить её до миграций
Аргумент функции называется так же, как колонка, и фильтр всегда истиненwhere site_key = site_key сравнивает колонку с собой#variable_conflict use_column и полное имя submit_form.site_key
Политики двух таблиц зациклилисьRLS одной таблицы читает другую и наоборотПроверки членства — функции security definer
Чужой сайт не может обращаться к Data APICORS пускает только адреса проектовiframe со своего адреса
Подключению Telegram нужен входящий вебхукData API не принимает входящие запросыОпрос getUpdates, пока владелец сайта на странице

Перед запуском в продакшен​

  • Проверяйте права скриптом после каждой миграции.
  • Смотрите, что видит каждая роль: Data API → «Методы» или npx layero@latest data methods --db inbox.
  • Задайте имя отправителя писем: npx layero@latest data auth sender "Ваш продукт".
  • Если владельцев сайтов станет много, перенесите проект на выделенную базу.

Что дальше​