Действие — это открытый эндпоинт
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Форма с действием выглядит как форма: поле, кнопка и функция рядом с разметкой. На сервере это обычный HTTP-эндпоинт с публичным адресом — и позвать его можно без вашей формы, без вашей кнопки и вообще без браузера. Значит, всё, что приезжает в действие, — те самые «данные извне» из части III, а права проверяются внутри действия, а не в разметке вокруг него.
В первой статье главы мы разобрали, что уходит по сети: сборка оставляет от действия строку-идентификатор, React кладёт её в форму, а отправка превращается в POST на адрес текущей страницы с этим идентификатором в заголовке next-action. Прочитайте это утверждение задом наперёд — и получится вся сегодняшняя статья. Если запрос с таким идентификатором вызывает функцию, то любой запрос с таким идентификатором вызывает функцию.
| Что нужно, чтобы позвать действие | Откуда это взять |
|---|---|
| адрес страницы, где действие объявлено | из адресной строки |
| идентификатор действия | из разметки той же страницы |
| поля запроса | написать любые |
| ваша форма, ваша кнопка, ваш JavaScript | не нужны вовсе |
Идентификатор непредсказуем и пересчитывается между сборками — но на этом его защитные свойства кончаются. Он лежит в разметке страницы у каждого, кому эту страницу показали; достать его оттуда и повторить запрос — упражнение на десять минут. Модель ниже огрубляет всё, кроме сути: вызов приходит по идентификатору, а разметка в этом не участвует. Прогноз перед запуском: сработает ли удаление, если кнопку никто не рисовал?
Условие {isAdmin && <DeleteButton />} из статьи про условия в разметке решает задачу интерфейса: не показывать человеку то, чем он не может воспользоваться. Задачу доступа оно не решает никак — оно вообще выполняется в другом месте и в другое время, чем действие.
| Приём | От чего защищает |
|---|---|
| не отрисовать кнопку | от случайного нажатия — и только |
required, maxLength, type="email" в разметке | от опечатки; отключается в инспекторе за секунду |
| проверка в клиентском компоненте перед вызовом | от опечатки; клиентский код целиком в руках посетителя |
проверка доступа в раскладке layout.tsx | ни от чего: действие выполнится раньше, чем что-либо начнёт отрисовываться |
| непредсказуемый идентификатор действия | от угадывания вслепую; из своей же страницы он читается |
| проверка сессии и входных данных внутри действия | от всего перечисленного |
Правило одной строкой: действие — не продолжение формы, а самостоятельный вход в приложение. Всё, что вы бы проверили в маршруте, написанном руками, проверяется и здесь.
Одну атаку Next закрывает сам. Чужой сайт может нарисовать у себя форму, ведущую на ваш адрес, и отправить её от имени вашего посетителя — вместе с его куками. Это подделка межсайтового запроса, CSRF. Поэтому Next сверяет у запроса к действию заголовок Origin с Host и отклоняет запрос, если они разошлись.
Отсюда единственная настройка, которая может понадобиться: если приложение стоит за обратным прокси и Host честно отличается от внешнего адреса, список допустимых источников задаётся в next.config — experimental.serverActions.allowedOrigins. И заметьте, чего эта проверка не делает: она отвечает на вопрос «откуда пришёл запрос», а не «кто его отправил» и «можно ли ему это».
У любого действия два вопроса: кто пришёл и что он просит сделать. Ошибка почти всегда одна и та же — оба ответа берут из одного места, из присланных данных. Идентификатор задачи в скрытом поле, идентификатор пользователя рядом с ним, и функция сравнивает одно с другим.
Прогноз перед запуском: обе функции получают одни и те же поля от одного и того же посетителя, и в полях указана чужая задача с её настоящим владельцем. Что ответит каждая?
Первая функция не сломана — она честно сравнила два числа. Беда в том, что оба числа прислал тот, у кого мы спрашиваем разрешения. Сравнение task.ownerId === fields.userId проверяет не права, а внутреннюю согласованность запроса: подставьте под чужую задачу её настоящего владельца — и проверка пройдёт.
Отсюда расстановка, которая годится для любого действия. Личность достаётся из сессии — из куки, которую расшифровывает сервер. Аргументы отвечают только на вопрос «над чем работаем». Право проверяется сравнением того, что лежит в хранилище, с тем, что сказала сессия, — и ни в одном из трёх шагов присланный «кто» не участвует.
На чужую задачу вторая функция отвечает «отказано», и это уже уступка примеру. В настоящем действии на чужое и на несуществующее лучше отвечать одинаково: разница в ответах — готовый способ перебором выяснить, какие записи у вас есть.
Шифрует. Действие, объявленное внутри серверного компонента, замыкает переменные этого компонента — и раз вызов приходит из браузера, замкнутое приходится возить туда и обратно. Чтобы эти значения не читались прямо в исходнике страницы, Next шифрует их ключом, который создаётся на каждую сборку. Скрытое поле формы, наоборот, лежит открытым текстом: там правится всё.
Только это ответ на другой вопрос. Шифрование говорит «значение не подменили», а не «отправитель имеет право»: идентификатор действия остаётся публичным, тот же запрос можно повторить через час, а сессия за это время успевает истечь. Да и рядом в той же форме лежат обычные поля, которые правятся свободно. Проверку внутри действия шифрование не заменяет — оно лишь избавляет от привычки считать разметку надёжным хранилищем.
Второй вопрос после «кто» — «что прислали». Здесь помощь приходит откуда не ждали: типы FormData написаны честно, и компилятор возражает раньше, чем вы успеете что-то испортить. Тип у get — FormDataEntryValue | null, а сам FormDataEntryValue — это File | string.
То есть в одном значении сразу два отказа: поля может не оказаться вовсе, а может приехать файл. Прогноз: сколько диагностик даст пример и что напечатают три последние строки?
Диагностика одна, на первом вызове, — и после неё код всё равно выполняется: тип string в сигнатуре не мешает файлу доехать до тела функции. Два вызова saveTask напечатали одно и то же, и в этом весь урок: as string — обещание компилятору, а не проверка. Обещание оказалось ложным, и title.length вернул undefined. А String() честно превратил файл в строку "[object File]" длиной 13 символов, а отсутствующее поле — в строку "null" длиной 4.
Оба «лекарства» одинаково вредны тем, что после них проверка title.length > 0 проходит. В базу уезжает задача с названием null, а рядом в логах — ни одной ошибки.
| Как читают поле | Что пропускает дальше |
|---|---|
form.get("x") as string | и null, и File — под видом строки |
String(form.get("x")) | "null" и "[object File]" — настоящие непустые строки |
String(form.get("x") ?? "") | "[object File]"; отсутствие поля становится пустой строкой |
typeof v === "string" | ничего: остаётся ровно строка, и это видит компилятор |
Это ровно тот разговор, который был в части III в статье «Данные извне — это unknown». Там источником был JSON.parse, здесь — FormData, но правило одно: тип на границе не утверждают, а проверяют, и проверку пишут так, чтобы её результат видел компилятор. typeof v === "string" сужает тип; as string просто велит компилятору замолчать.
В примерах прошлых статей главы поля читались через String(formData.get("title") ?? "") — для показа механики этого достаточно, для настоящего действия мало. Разбор из следующего раздела и есть недостающая часть.
С числами всё то же самое, только незаметнее: строку в число превращает Number, и он почти никогда не жалуется. Прогноз: три формы — заполненная как надо, лишившаяся поля amount и присланная со злым умыслом. Что окажется в каждом объекте?
Пропавшее поле дало не NaN, а честный ноль: get вернул null, а Number(null) — это 0. Проверка Number.isNaN(amount), которую сюда обычно и дописывают, промолчит. Пустая строка и строка из одних пробелов дают тот же ноль, и защиты от отрицательного количества здесь нет никакой. Заодно посмотрите на первую строку вывода: даже честная форма привезла заголовок вместе с пробелами по краям — до базы доедут и они.
Number("0x10") — это 16, а Number("1e3") — 1000. Форма прислала строку, а не число, и как именно эта строка станет числом, решаете вы, а не отправитель.
Лечится это не россыпью if по всему действию, а одной функцией разбора: на входе FormData, на выходе — размеченное объединение из части III. Либо готовое значение нужного типа, либо причина отказа; третьего не дано, и компилятор за этим следит.
Заодно решилась задача из второй статьи главы: parsed.ok === false — это уже готовое состояние формы, которое действие возвращает через useActionState, и текст отказа в нём лежит рядом с меткой. Проверка на сервере и сообщение читателю получаются из одного разбора, а не пишутся дважды.
И оговорка про парность проверок. Браузерная проверка не бесполезна — она нужна, но занята другим делом.
| Где проверка | Зачем она | Что будет, если её убрать |
|---|---|---|
в разметке: required, pattern, maxLength | подсказать человеку до отправки | лишний круг до сервера и обратно |
| в клиентском компоненте | живая подсветка ошибки по мере ввода | форма станет менее удобной |
| в действии, на сервере | решить, менять ли данные | в базе окажется что угодно |
В настоящем проекте вместо руками написанного parseNewTask обычно берут библиотеку схем — zod, valibot, что-то из этого ряда. Схема описывается один раз, а тип значения выводится из неё, и повторять его отдельным interface не приходится.
Меняется от этого только объём кода. Схема живёт на сервере, вызывается первой строкой действия и возвращает то же самое: разобранное значение или список причин отказа. Понимать, зачем оно там стоит, важнее, чем помнить синтаксис конкретной библиотеки, — поэтому в этой статье разбор написан руками.
К тому же привязка к конкретной библиотеке слабее, чем кажется. У zod и valibot есть общий интерфейс Standard Schema, и тому, кто читает схему через него, всё равно, какой библиотекой она написана. Так одна схема уходит и в форму, и в действие; как это устроено, разобрано на полке React и Next.
Про вход мы договорились. Осталась вторая половина границы: то, что действие возвращает, тоже пересекает сеть и попадает в браузер — а через useActionState ещё и оседает в состоянии формы. Вернуть из действия строку, которую вернула база, — значит отправить её читателю целиком.
Прогноз: тип обещает два поля, а функция отдаёт объект из четырёх. Что скажет компилятор и что окажется в выводе?
Компилятор промолчал — и был прав по своим правилам. Типизация в TypeScript структурная: объект с четырьмя полями подходит везде, где ждут объект с двумя, потому что нужные два у него есть. Про лишние поля компилятор ругается только на свежих литералах — это разбиралось в части III. Здесь литерала нет, есть возвращённое значение функции, и оно проходит молча вместе с хешем пароля.
Тип, который «сужает» ответ, ничего не срезает: типы стираются на сборке, а в браузер едет обычный объект. Срезать поля должен код — и вот тут строгость к литералам работает уже на вас.
Диагностика на последнем объявлении — та самая проверка лишних свойств: «объектный литерал может использовать только известные свойства». Она и есть причина писать отображение руками: пока ответ собирается из полей по одному, любое лишнее поле в нём — ошибка сборки, а не тихая утечка.
Тот же разговор про тексты ошибок. Во второй статье главы мы договорились ошибку возвращать, а не бросать; здесь добавляется, что возвращать нужно свой текст, а не сообщение исключения: в нём легко приезжают имена таблиц, куски запроса и путь к файлу на сервере.
Всё сказанное складывается в один порядок, и порядок этот не случайный: сначала дешёвые проверки, потом обращения к базе, работа — последней. Сессия не подтвердилась — не надо разбирать поля. Поля не разобрались — не надо ходить в базу.
Пример ничего не печатает, и так задумано: сервера у песочницы нет, объявленных функций тоже. Проверяемо здесь другое — что каркас проходит через компилятор со strict: true и что типы в нём сходятся.
Двадцать строк обвязки на одно переименование выглядят избыточно ровно до первого раза, когда их не написали. В настоящем проекте первые два шага уезжают в общую функцию вроде requireUser(), а третий — в правило доступа рядом с моделью данных; в самом действии остаётся четыре вызова и работа. Но исчезнуть из действия эти шаги не могут: другого места, где запрос впервые встречается с вашими данными, у него нет.
| Шаг | Откуда берутся данные | Чего здесь не делают |
|---|---|---|
| личность | сессия, расшифрованная на сервере | не читают userId из формы и из привязанных аргументов |
| разбор входа | FormData через проверки типа | не пишут as string и не полагаются на required в разметке |
| право | запись, поднятая из хранилища | не сравнивают присланное с присланным |
| работа и обновление | разобранное значение | не возвращают наружу строку из базы целиком |
- Server Action — публичный эндпоинт: вызов приходит по идентификатору действия, и ни форма, ни кнопка, ни ваш JavaScript для этого не нужны.
- Условная отрисовка, атрибуты
requiredи проверки в клиентском компоненте — это удобство, а не защита: всё это выключается в инспекторе. - «Кто пришёл» берётся только из сессии; аргументы и скрытые поля отвечают лишь на вопрос «над чем работаем», и право проверяется на записи из хранилища.
formData.getотдаётFormDataEntryValue | null:as stringиString()не проверяют тип, а маскируют его — проверяетtypeof v === "string".Numberотсутствующего поля, пустой строки и пробелов — это0, а неNaN: вход разбирают функцией, возвращающей размеченное объединение.- Возвращённое значение тоже пересекает границу: строку из базы отдавать наружу нельзя, ответ собирают литералом из перечисленных полей.
1/4Кнопка «Удалить проект» рисуется только администратору: {isAdmin && <DeleteButton />}. Достаточно ли этого, чтобы не проверять права внутри самого действия?
2/4Какой тип у выражения formData.get("title")?
3/4Поле amount убрали из формы в инспекторе и отправили. Что даст Number(formData.get("amount")) в действии?
4/4Действие объявлено как Promise<PublicUser> с полями id и name, а возвращает строку из базы, где есть ещё и passwordHash. Что произойдёт?
Действие, которое верит форме
Действие удаляет комментарий и проверяет права — но берёт всё, включая личность автора, из присланных полей. Перепишите его так, чтобы «кто» приходило из сессии, а из формы — только идентификатор комментария. Функция sessionUser уже есть.
Личность берётся из сессии, а из полей остаётся только «что»: идентификатор комментария. Сравнивается запись, поднятая из хранилища, с тем, что сказала сессия.
Обратите внимание и на второй ответ: чужой комментарий получает тот же текст, что и несуществующий. Разные ответы позволили бы перебором выяснить, какие записи в базе есть, — а поле authorId из формы теперь можно просто удалить, оно ни на что не влияет.
Разбор одного поля
Напишите parseRating(form): оценка приходит в поле rating и должна быть целым числом от 1 до 5. Возвращайте размеченное объединение — { ok: true, value } либо { ok: false, error }. Все четыре подготовленные формы должны получить верный ответ.
Сначала проверка типа — она отсекает и отсутствующее поле, и файл, — потом преобразование, потом диапазон. Одна Number.isInteger закрывает сразу NaN, дробное и бесконечность.
Проверять отдельно на NaN не нужно: Number.isInteger(NaN) — это false. А вот проверить границы обязательно: без них Number.isInteger пропустит и ноль, и минус сотню.
Что вернуть наружу
Действие возвращает профиль, и компилятор не возражает — а в браузер уезжает вся строка из базы. Соберите ответ так, чтобы наружу попали только id и name.
Ответ собирается литералом, поле за полем. Это не только режет лишнее, но и включает проверку лишних свойств: приписать сюда email компилятор уже не даст.
Соблазнительный вариант с деструктуризацией — const { passwordHash, ...rest } = user; return rest; — работает ровно один раз: как только в таблице появится новая колонка, она уедет наружу сама. Перечисление того, что отдаём, надёжнее перечисления того, что прячем.