Закрытая админка на Vue собирается на сценариях вашего сайта

У каждого проекта на DynapiCMS есть встроенная админка. Иногда проекту нужна вторая, своя, с интерфейсом под конкретную задачу и доступом для узкого круга людей. Такая админка собирается из того, что уже есть в платформе: типы контента, сценарии и статика сайта. Писать для неё отдельный бэкенд не нужно.

Разберём весь путь на примере каталога библиотеки: Vue-приложение со списком книг, вход по email и паролю, добавление и удаление записей. По ходу покажем и обвязку (шаблон-обёртку на Liquid), и код самого приложения, и то, как передать ему данные ещё до первого запроса. GraphQL при этом остаётся внутри платформы.

Учётные записи: тип «Читатель»

Закрытая админка начинается с вопроса, кого пускать. Заводим тип контента «Читатель» с тремя полями: email (строка, обязательное поле), password и verified.

Галерея пресетов в редакторе сценариев: карточки «Страница», «Вход», «Регистрация», «Забыли пароль», «Смена пароля», «Выход»

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

Вход: четыре узла из пресета

В редакторе сценариев есть галерея пресетов. Пресет «Вход» собирает готовую цепочку: GraphQL-запрос ищет запись по email из формы, условие сверяет пароль оператором bcrypt_matches, set_cookie ставит сессию, redirect ведёт на страницу успеха. При неудаче сценарий переадресует человека обратно на /login?failed=1.

Редактор сценария входа: узлы Начало, GraphQL, Условие bcrypt_matches, Установка cookie member и два Редиректа

Кука называется member, живёт 30 дней и помечена HttpOnly, Secure и SameSite=Lax. Внутри не данные читателя, а подписанная AES-обёртка: из логов и истории браузера из неё ничего не вытащить, а прочитать содержимое может только сервер проекта.

Странице входа отдельный интерфейс не нужен: маршрут /login принимает два метода, GET отдаёт Liquid-шаблон с обычной HTML-формой, POST уходит в сценарий. От формы сценарию нужны только имена полей:

<form method="post" action="/login">
  <input name="email" type="email" required>
  <input name="password" type="password" required>
  <button>Войти</button>
</form>

Всё, что должно быть видно только после входа, помечается на маршруте флагом members_only с адресом страницы входа. Дальше платформа делает остальное сама.

JSON-API на сценариях

Каталогу нужны три операции: список, добавление, удаление. Каждая — обычный маршрут сайта со сценарием. Сценарий (в коде — флоу) составляется из узлов; для каталога хватает двух действий: graphql обращается к данным проекта, respond_json отвечает клиенту.

Сценарий списка привязан к GET /api/books и целиком помещается в один файл:

{
  "version": 2,
  "nodes": [
    { "id": "n_start", "type": "entry", "data": {} },
    {
      "id": "a_q",
      "type": "action",
      "data": {
        "action_type": "graphql",
        "config": {
          "query": "query { books(limit: 200, orderBy: [{field: CREATED_AT, direction: DESC}]) { id title author year status } }",
          "result_key": "books"
        }
      }
    },
    {
      "id": "a_out",
      "type": "action",
      "data": {
        "action_type": "respond_json",
        "config": { "status": 200, "body_template": "{{ results.books | json }}" }
      }
    }
  ],
  "edges": [
    { "from": "n_start", "to": "a_q", "branch": "" },
    { "from": "a_q", "to": "a_out", "branch": "" }
  ]
}

Тела POST-запросов разбираются в неймспейсы. Форма с сайта попадает в form., JSON в json., XML в xml.*. Приложение шлёт fetch с Content-Type: application/json, и поля читаются как json.title, json.author. Запрос с чужим Content-Type сервер отвечает кодом 415.

Сценарий добавления принимает POST /api/books/add. Поля из JSON-тела подставляются в переменные мутации, результат уходит обратно:

{
  "id": "a_create",
  "type": "action",
  "data": {
    "action_type": "graphql",
    "config": {
      "query": "mutation($title: String!, $author: String!, $year: Int, $status: String) { createBook(input: {title: $title, author: $author, year: $year, status: $status}) { id title author year status } }",
      "variables": {
        "title": "{{json.title}}",
        "author": "{{json.author}}",
        "year": "{{json.year | plus: 0}}",
        "status": "{{json.status | default: \"in_stock\"}}"
      },
      "result_key": "created"
    }
  }
}

За ним стоит узел respond_json с body_template {{ results.created | json }} — клиент получает созданную запись и обновляет список. Удаление устроено так же: deleteBook по json.id и короткий ответ {ok: true}.

Редактор сценария books_add: graphql-мутация createBook и узел respond_json, маршрут /api/books/add

Сценарий проверяется через dry-run ещё до публикации: прогон идёт без побочных эффектов и показывает шаги с директивами. У опубликованных сценариев есть журнал прогонов, где видно, сколько раз сработал каждый узел и на чём упало.

Обёртка: Liquid-шаблон и статика

SPA собирается Vite (Vue 3 + TypeScript) и заливается в статику сайта одной командой:

flowctl files push dist --as static --publish

Файлы ложатся в /static/, откуда сайт раздаёт их до обработки маршрутов. Маршрут /admin отдаёт Liquid-шаблон-обёртку — вот он целиком:

<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Каталог — Библиотека</title>
  <link rel="stylesheet" href="/static/admin.css?v=4">
</head>
<body>
  <div id="app"></div>
  <script>
    window.__BOOT__ = {
      member: {{ member | json }},
      email: {{ results.me.members[0].email | json }}
    };
  </script>
  <script type="module" src="/static/admin.js?v=4"></script>
</body>
</html>

Три детали, на которые стоит обратить внимание.

Mustache-конфликта здесь нет. Liquid разбирает {{ }} как свои вставки, и наивный перенос Vue-шаблона в HTML-файл сломался бы на первом же {{ title }}. Конфликта не возникает, потому что Vite компилирует шаблоны .vue в render-функции ещё на сборке: в готовом admin.js декоративных скобок нет, а шаблон-обёртка содержит только пустой div#app. Приложение без сборщика (Vue с CDN и шаблоном прямо в HTML) пришлось бы экранировать через v-pre или собственные delimiters.

Ссылки на статику версионированы (?v=4), поэтому после перевыкладки сборки браузер не держит старый admin.js в кэше. Суффикс для раздающего сервера прозрачен, отдаётся тот же файл. Скрипт window.__BOOT__ в середине шаблона наполняется данными сервера, об этом отдельный раздел ниже.

Приложение на Vue

Приложению не нужен ни Apollo, ни даже свой слой работы с куки: все запросы идут в те же маршруты, кука member едет с каждым fetch сама, потому что приложение и API живут на одном origin. Весь слой данных — три функции:

const boot = (window as any).__BOOT__ ?? {};
const books = ref<Book[]>([]);
const form = ref({ title: "", author: "", year: "" });

async function load() {
  const res = await fetch("/api/books", { headers: { Accept: "application/json" } });
  if (res.redirected || res.status === 403) {
    window.location.href = "/login";
    return;
  }
  books.value = (await res.json()).books ?? [];
}

async function add() {
  const res = await fetch("/api/books/add", {
    method: "POST",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify({
      title: form.value.title,
      author: form.value.author,
      year: form.value.year ? Number(form.value.year) : null,
    }),
  });
  if (res.ok) {
    form.value = { title: "", author: "", year: "" };
    await load();
  }
}

async function remove(id: string) {
  await fetch("/api/books/delete", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ id }),
  });
  await load();
}

onMounted(load);

Перенаправление на /login — реакция на гейт: когда сессия кончилась, API не отдаёт данные, а уводит на страницу входа. В шаблоне компонента остаётся обычная таблица с состоянием:

<tr v-for="b in books" :key="b.id">
  <td class="title">{{ b.title }}</td>
  <td>{{ b.author }}</td>
  <td class="num">{{ b.year ?? "—" }}</td>
  <td><span class="pill" :class="b.status === 'on_loan' ? 'loan' : 'stock'">{{ statusLabel(b.status) }}</span></td>
  <td class="num"><button class="del" @click="remove(b.id)">Удалить</button></td>
</tr>

Каталог книг на поддомене проекта: email читателя в шапке, форма добавления и таблица с четырьмя книгами

Форма «Новая книга» с заполненными полями: Преступление и наказание, Фёдор Достоевский, 1866

Данные при заходе: boot-пэйлоад

Список книг приложение догружает само, а вот данные о том, кто открыл страницу, можно отдать сразу, ещё до первого fetch. Маршрут /admin привязан к GET-сценарию из двух узлов:

{
  "version": 2,
  "nodes": [
    { "id": "n_start", "type": "entry", "data": {} },
    {
      "id": "a_me",
      "type": "action",
      "data": {
        "action_type": "graphql",
        "config": {
          "query": "query($id: ID!) { members(where: {id: {eq: $id}}, limit: 1) { id email } }",
          "variables": { "id": "{{ member.id }}" },
          "result_key": "me"
        }
      }
    },
    {
      "id": "a_render",
      "type": "action",
      "data": {
        "action_type": "render",
        "config": { "template": "admin.liquid" }
      }
    }
  ],
  "edges": [
    { "from": "n_start", "to": "a_me", "branch": "" },
    { "from": "a_me", "to": "a_render", "branch": "" }
  ]
}

Механика такая. Внутри сценария сессия доступна как member.*: то, что login-пресет положил в куку, движок раскладывает в контекст, и {{ member.id }} подставляет id читателя в переменную запроса. graphql-узел выбирает профиль, результат оседает в results.me, и render-узел рендерит шаблон с этим же контекстом. Шаблон складывает обе величины в window.__BOOT__, а приложение читает их при монтировании и рисует email в шапке — без whoami-запроса.

Два случая стоит держать в голове. Если graphql-узел упал (on_error: continue), results.me будет пуст, в __BOOT__ уедет email: null, а интерфейс покажет нейтральное «сотрудник». А для страниц, собираемых из библиотеки запросов (queryRefs маршрута), есть и зарезервированная переменная $member: платформа подставляет в неё id сессии и перезаписывает клиентское значение, так что фильтр по чужому id через неё собрать не выйдет.

Почему это закрыто

Анонимный GET на members_only-странице уводится на страницу входа (302). Анонимный POST отклоняется до того, как сценарий начнёт работать: JSON-клиент получает 403 AUTHENTICATION_REQUIRED, браузерная форма — 303 на адрес входа. Бюджет запросов на такое обращение не тратится.

Идентификация живёт на сервере. В сценарии контекст member.* собирается из подписанной куки, которую браузер не читает (HttpOnly) и не подделает. На query-пути клиентское значение $member перезаписывается id сессии. Ответы, зависящие от сессии, в общий кэш страниц не попадают: один читатель не увидит чужую версию страницы.

Про капчу стоит знать заранее. Если она включена в настройках рендера, каждый публичный POST, включая вход, обязан нести токен, иначе сервер отвечает 403 CAPTCHA_FAILED. POST с чужого Origin отклоняется кодом REQUEST_ORIGIN_DENIED, это механизм против login-CSRF. В примере из статьи капча выключена, для продакшена её добавляет пресет «Форма с капчей».

Правки без релизов

Типы, сценарии и маршруты остаются в админке проекта и правятся в любой момент. Поменять поля каталога или добавить операцию значит изменить тип или сценарий, без выкладки нового кода. Готовые пресеты входа, регистрации и сброса пароля разобраны в гайде по пресету «Вход».


Все записи