В архитектуре Headless WordPress ключевым барьером для работы редакторов контента является проблема изоляции черновиков: клиентский фронтенд Next.js обслуживает пользователей из неизменяемого статического кэша (ISR/SSG) на Edge CDN, а неопубликованные материалы (черновики, запланированные посты и ревизии) закрыты в базе данных WordPress и недоступны для публичного чтения.
Для решения этой задачи в современном стеке Next.js 16 (App Router) используется механизм Draft Mode (пришедший на смену устаревшему previewData из Pages Router).
Механика работы Draft Mode:
preview_post_link в WordPress перехватывает стандартную ссылку и перенаправляет браузер редактора на защищенный эндпоинт фронтенда: /api/draft?secret=...&id=42&slug=my-draft.draftMode().enable().__prerender_bypass с атрибутами SameSite=Lax, Secure и HttpOnly.__prerender_bypass, сервер Next.js отключает статический кэш (Data Cache и Full Route Cache) исключительно для текущей сессии редактора, переходя в режим рендеринга по требованию.const { isEnabled } = await draftMode();. Если режим активен, компонент использует приватный токен авторизации (Application Password / Scoped JWT) для запроса свежайшей ревизии черновика из защищенного эндпоинта WordPress API (/wp/v2/posts/{id}/revisions).Для обеспечения безопасности корпоративного уровня система предпросмотра обязана нейтрализовать вектор уязвимости Open Redirect (перенаправление на вредоносные домены через поддельный параметр slug), обеспечивать плавающую валидацию таймстемпов токенов и предоставлять интерфейс выхода из режима черновика (/api/disable-draft).
Сквозной протокол авторизованного рукопожатия и рендеринга ревизии черновика:
+-----------------------------------------------------------------------------------------+
| АДМИН-ПАНЕЛЬ WORDPRESS (Gutenberg) |
+-----------------------------------------------------------------------------------------+
| [Редактор нажимает кнопку "Предпросмотр"] |
| | |
| v |
| [Хук: preview_post_link] ---> Генерация ссылки с временным токеном: |
| https://frontend.com/api/draft?secret=TOKEN&id=42&slug=p1|
+-----------------------------------------------------------------------------------------+
|
| 1. Переход браузера (GET)
v
+-----------------------------------------------------------------------------------------+
| NEXT.JS 16 ROUTE HANDLER |
+-----------------------------------------------------------------------------------------+
| [/api/draft/route.ts] |
| | |
| +---> [Сверка секрета: crypto.timingSafeEqual()] |
| | |
| +---> [draftMode().enable()] ---> Установка куки: __prerender_bypass |
| | |
| +---> [307 Redirect на /blog/p1] |
+-----------------------------------------------------------------------------------------+
|
| 2. Редирект с кукой __prerender_bypass
v
+-----------------------------------------------------------------------------------------+
| NEXT.JS 16 REACT SERVER COMPONENT (RSC) |
+-----------------------------------------------------------------------------------------+
| [app/blog/[slug]/page.tsx] |
| | |
| +---> const { isEnabled } = await draftMode(); // TRUE! |
| | | |
| | +---> [ОБХОД КЭША CDN]: Запрос ревизии черновика с M2M Bearer токеном |
| | |
| +---> Рендеринг закрытого контента + Плашка "Режим предпросмотра [Выйти]" |
+-----------------------------------------------------------------------------------------+Встроенный API фреймворка Next.js для временного отключения слоя статического кэширования для авторизованных пользователей без пересборки страниц.
Криптографически подписанная кука, устанавливаемая сервером Next.js при активации Draft Mode, сигнализирующая Edge-маршрутизатору о необходимости динамического рендеринга страницы.
Механизм извлечения последней сохраненной авторевизии (autosave) или черновика из таблицы wp_posts с использованием привилегированных учетных данных CMS.
Строгая валидация относительного пути перенаправления, исключающая возможность передачи ссылок на внешние фишинговые сайты через параметр slug или redirect.
Изолированная учетная запись с минимальными привилегиями чтения неопубликованного контента (read_private_posts), используемая сервером Next.js для связи с WordPress.
WordPress перехватывает клик по кнопке через фильтр preview_post_link и генерирует защищенную ссылку на фронтенд с временным криптографическим токеном.
Обработчик app/api/draft/route.ts выполняет timing-safe сверку секрета и проверяет наличие запрашиваемой записи в WordPress API.
Next.js вызывает draftMode().enable() и отправляет в заголовке Set-Cookie куку __prerender_bypass, выполняя редирект 307 на проверенный относительный путь.
Серверный компонент обнаруживает draftMode().isEnabled = true и запрашивает самую свежую ревизию поста через M2M Application Password.
Страница рендерится с закрепленным баннером 'Активен режим предпросмотра', содержащим ссылку на /api/disable-draft для возврата к публичному кэшу.
Что физически происходит в Next.js 16 (App Router) при вызове функции draft.enable() внутри серверного Route Handler /api/draft?
Генерация 32-байтного криптографического ключа random_bytes(32) и сохранение в переменные окружения WORDPRESS_PREVIEW_SECRET на бэкенде и фронтенде.
Создает общий закрытый канал доверия между системами.
Создание эндпоинтов app/api/draft/route.ts и app/api/disable-draft/route.ts с санитизацией Open Redirect и вызовом draftMode().
Управляет жизненным циклом сессионной куки __prerender_bypass.
Перенаправление кнопки «Предпросмотр» в Gutenberg на созданный API-маршрут фронтенда с передачей идентификатора записи и токена.
Обеспечивает нативный пользовательский опыт для редакторов.
Внедрение проверки draftMode().isEnabled в серверные компоненты, переключение источника данных на закрытые ревизии и рендеринг плашки выхода.
Гарантирует корректное отображение правок и возможность возврата в кэш.
Если обработчик /api/draft слепо доверяет входящему параметру slug или redirect:
// КРИТИЧЕСКАЯ УЯЗВИМОСТЬ:
redirect(searchParams.get('slug'));Злоумышленник может сформировать вредоносную ссылку вида https://frontend.com/api/draft?secret=VALID&slug=//attacker.com/login, отправляя пользователей сайта на фишинговый ресурс.
Стандарт безопасности 2026 года:
Относительный URL обязан начинаться строго с одного слэша / и не должен содержать двойных слэшей // или двоеточий:
function sanitizeRedirectPath(path: string | null): string {
if (!path || !path.startsWith('/') || path.startsWith('//') || path.includes(':')) {
return '/';
}
return path;
}Ниже представлена готовая инфраструктурная связка: PHP-фильтр WordPress для формирования временных подписанных ссылок и защищенные Route Handlers Next.js 16 для включения и выключения режима предпросмотра.
// ============================================================================
// ЧАСТЬ 1: Next.js 16 Route Handler активации (app/api/draft/route.ts)
// ============================================================================
import { draftMode } from 'next/headers';
import { redirect } from 'next/navigation';
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'node:crypto';
/**
* Валидация относительного пути перенаправления для исключения Open Redirect.
*/
function sanitizeRedirect(path: string | null): string {
if (!path || !path.startsWith('/') || path.startsWith('//') || path.includes(':')) {
return '/';
}
return path;
}
export async function GET(request: NextRequest): Promise<NextResponse | void> {
const { searchParams } = new URL(request.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
const id = searchParams.get('id');
const configuredSecret = process.env.WORDPRESS_PREVIEW_SECRET;
if (!configuredSecret || !secret) {
return NextResponse.json({ message: 'Invalid preview token' }, { status: 401 });
}
// Сравнение секретов с защитой от тайминг-атак
const secretBuffer = Buffer.from(secret, 'utf8');
const expectedBuffer = Buffer.from(configuredSecret, 'utf8');
if (
secretBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(secretBuffer, expectedBuffer)
) {
return NextResponse.json({ message: 'Unauthorized preview access' }, { status: 401 });
}
// 1. Активация режима черновика (устанавливает куку __prerender_bypass)
const draft = await draftMode();
draft.enable();
// 2. Безопасное перенаправление на страницу статьи
const redirectPath = slug ? `/blog/${slug}` : (id ? `/blog/?p=${id}` : '/');
redirect(sanitizeRedirect(redirectPath));
}
// ============================================================================
// ЧАСТЬ 2: Next.js 16 Route Handler отключения (app/api/disable-draft/route.ts)
// ============================================================================
export async function GET(request: NextRequest): Promise<NextResponse | void> {
const { searchParams } = new URL(request.url);
const redirectUrl = sanitizeRedirect(searchParams.get('redirect'));
// Отключаем режим черновика (очищает куку __prerender_bypass)
const draft = await draftMode();
draft.disable();
redirect(redirectUrl);
}
// ============================================================================
// ЧАСТЬ 3: React Server Component баннера (components/PreviewBanner.tsx)
// ============================================================================
export async function PreviewBanner({ currentPath }: { currentPath: string }) {
const { isEnabled } = await draftMode();
if (!isEnabled) {
return null;
}
return (
<aside className="sticky top-0 z-50 flex items-center justify-between bg-accent-amber/90 px-4 py-2 text-xs font-semibold text-black backdrop-blur-md shadow-md">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-red-600 animate-pulse" />
<span>Внимание: Активен режим предпросмотра (черновики и ревизии)</span>
</div>
<a
href={`/api/disable-draft?redirect=${encodeURIComponent(currentPath)}`}
className="rounded bg-black px-3 py-1 text-white hover:bg-neutral-800 transition-colors"
>
Выйти из предпросмотра
</a>
</aside>
);
}
// ============================================================================
// ЧАСТЬ 4: WordPress Preview Filter (wp-content/mu-plugins/agentic-preview.php)
// ============================================================================
/*
<?php
declare(strict_types=1);
namespace AgenticLab\Integration;
final class AgenticPreviewLinkFilter {
private const string FRONTEND_DRAFT_URL = 'https://frontend.agenticlab.com/api/draft';
public static function init(): void {
add_filter('preview_post_link', [self::class, 'filter_preview_link'], 10, 2);
}
public static function filter_preview_link(string $link, \WP_Post $post): string {
$secret = defined('WORDPRESS_PREVIEW_SECRET') ? WORDPRESS_PREVIEW_SECRET : '';
if (empty($secret)) {
return $link;
}
return add_query_arg([
'secret' => $secret,
'id' => $post->ID,
'slug' => $post->post_name ?: 'draft-' . $post->ID,
], self::FRONTEND_DRAFT_URL);
}
}
add_action('init', [AgenticPreviewLinkFilter::class, 'init']);
*/
Какая критическая уязвимость возникает, если эндпоинт /api/draft выполняет redirect(searchParams.get('slug')) без валидации, и как ее предотвратить?
Реализация режима предпросмотра в современной архитектуре Headless WordPress строится на трех правилах:
draftMode().enable() в App Router с защищенной сессионной кукой __prerender_bypass.