$handler
*/
public static function afterRestSave($response, array $handler, \WP_REST_Request $request): mixed
{
if (self::isRestPostWrite($request)) {
$target = self::restTarget($request);
if ($target !== null) {
self::release($target['id'], $target['type']);
}
}
return $response;
}
/**
* Adds `llamly_edit_token` to a single-item GET with `context=edit` of a guarded type, for users who can edit it.
* The editor keeps the first token it sees; API clients resend it as `X-Llamly-Edit-Token`.
*
* @param mixed $result
* @return mixed
*/
public static function addEditToken($result, \WP_REST_Server $server, \WP_REST_Request $request): mixed
{
if (!$result instanceof \WP_REST_Response || $result->is_error() || $result->get_status() !== 200 || strtoupper((string) $request->get_method()) !== 'GET' || (string) $request->get_param('context') !== 'edit' || !is_user_logged_in()) {
return $result;
}
$target = self::restTarget($request);
if ($target === null || $target['autosave'] || !current_user_can('edit_post', $target['id'])) {
return $result;
}
$data = $result->get_data();
if (is_array($data) && !isset($data[0])) {
$data['llamly_edit_token'] = self::versionToken($target['id'], $target['type']);
$result->set_data($data);
}
return $result;
}
public static function guardClassicSave(): void
{
$postId = isset($_POST['post_ID']) ? absint($_POST['post_ID']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- the update-post nonce is verified right below, before any effect.
if ($postId < 1 || !self::guarded((string) get_post_type($postId)) || !current_user_can('edit_post', $postId)) {
return;
}
// Same nonce core checks for this save; on failure core's own check_admin_referer() rejects the request.
if (!isset($_POST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash((string) $_POST['_wpnonce'])), 'update-post_' . $postId)) {
return;
}
$token = isset($_POST['_llamly_edit_token']) ? sanitize_text_field(wp_unslash((string) $_POST['_llamly_edit_token'])) : '';
$error = self::acquireAndValidate($postId, $token);
if (is_wp_error($error)) {
wp_die(
esc_html($error->get_error_message()) . '' . esc_html__('Reload this editor after copying your unsaved text.', 'llamly') . '
',
esc_html__('Llamly blocked a stale editor save', 'llamly'),
['response' => 409, 'back_link' => true]
);
}
}
/** @param array $response
* @param array $data
* @return array
*/
public static function guardHeartbeat(array $response, array $data): array
{
$autosave = $data['wp_autosave'] ?? null;
if (!is_array($autosave) || empty($autosave['post_id'])) {
return $response;
}
$postId = absint($autosave['post_id']);
if ($postId < 1 || !self::guarded((string) get_post_type($postId)) || !current_user_can('edit_post', $postId)) {
return $response;
}
$token = isset($data['llamly_edit_token']) ? sanitize_text_field((string) $data['llamly_edit_token']) : '';
$error = self::acquireAndValidate($postId, $token);
if (is_wp_error($error)) {
remove_filter('heartbeat_received', 'heartbeat_autosave', 500);
$response['wp_autosave'] = [
'success' => false,
'llamly_stale' => true,
'code' => $error->get_error_code(),
];
self::release($postId);
}
return $response;
}
/** @param array $response
* @param array $data
* @return array
*/
public static function releaseHeartbeat(array $response, array $data): array
{
$autosave = $data['wp_autosave'] ?? null;
if (is_array($autosave) && !empty($autosave['post_id'])) {
self::release(absint($autosave['post_id']));
}
return $response;
}
public static function releaseAll(): void
{
foreach (self::$heldPostLocks as $held) {
self::release($held['id'], $held['type']);
}
}
private static function acquireAndValidate(int $postId, string $token, string $type = 'post', bool $apiClient = false): bool|\WP_Error
{
if (!ObjectMutex::acquire($type, $postId, 10)) {
return self::editorError('llamly_operation_in_progress', __('Another Llamly or editor save is in progress. Try again shortly.', 'llamly'));
}
self::$heldPostLocks[$type . ':' . $postId] = ['type' => $type, 'id' => $postId];
global $wpdb;
$version = ObjectVersionStore::get($type, $postId);
if ($version === null) {
// No row means Llamly never changed this post; a failed lookup must not release a stale editor.
if ($wpdb->last_error !== '') {
return self::editorError('llamly_version_unavailable', __('Llamly could not confirm the post version. Copy your unsaved text and try again.', 'llamly'));
}
return true;
}
if ($version['version'] < 1) {
// Row created by an aborted or not yet committed operation: Llamly never changed the object.
return true;
}
if ($token === '') {
return self::editorError('llamly_edit_token_required', $apiClient
? __('Read the item with context=edit and resend its llamly_edit_token as the X-Llamly-Edit-Token header.', 'llamly')
: __('This post changed through Llamly. Reload the editor before saving.', 'llamly'));
}
if (!preg_match('/^' . preg_quote((string) $postId, '/') . '\\.(0|[1-9][0-9]*)$/', $token, $match)) {
return self::editorError('llamly_stale_editor', __('This editor token is invalid. Copy your unsaved text, then reload the editor.', 'llamly'));
}
if ((int) $match[1] !== $version['version']) {
return self::editorError('llamly_stale_editor', __('This post changed while the editor was open. Copy your unsaved text, then reload before saving.', 'llamly'));
}
return true;
}
private static function isRestPostWrite(\WP_REST_Request $request): bool
{
// get_method() already reflects the override the core applied (`_method`, X-HTTP-Method-Override, batch).
$method = strtoupper((string) $request->get_method());
return in_array($method, ['POST', 'PUT', 'PATCH'], true) && self::restTarget($request) !== null;
}
/**
* The object a REST request targets, for every guarded post type: the route is matched against each type's own REST
* namespace and base (CPTs and wp_navigation use their own), then the post's type is confirmed. Block templates and
* template parts have a textual ID (`theme//slug`); it is resolved to the override post with the same
* `get_block_template()` call the controller makes, and the version row is the one of the template adapter. A
* template that is only a theme file has no override and no Llamly version, so it has no target.
*
* @return array{type:string,id:int,autosave:bool}|null
*/
private static function restTarget(\WP_REST_Request $request): ?array
{
$route = (string) $request->get_route();
// `//[/autosaves]`, any namespace shape; the prefix must be the one the post's type registered.
if (preg_match('#^/(.+?)/([0-9]+)(/autosaves)?$#i', $route, $match)) {
$postId = (int) $match[2];
$type = (string) get_post_type($postId);
if ($type === '' || !self::guarded($type)) {
return null;
}
return strtolower(self::restPrefix($type)) === strtolower($match[1]) ? ['type' => 'post', 'id' => $postId, 'autosave' => ($match[3] ?? '') !== ''] : null;
}
if (!preg_match('#^/(wp/v2/(?:templates|template-parts))/(.+)$#i', $route, $match)) {
return null;
}
$id = $match[2];
$autosave = false;
if (preg_match('#^(.+[^/])/autosaves$#i', $id, $stripped)) {
$id = $stripped[1];
$autosave = true;
}
foreach (self::TEMPLATE_TYPES as $templateType) {
if (strtolower(self::restPrefix($templateType)) !== strtolower($match[1])) {
continue;
}
$wpId = self::templateWpId($id, $templateType);
return $wpId > 0 ? ['type' => 'template', 'id' => $wpId, 'autosave' => $autosave] : null;
}
return null;
}
private static function templateWpId(string $restId, string $type): int
{
if (strpos($restId, '//') === false) {
return 0;
}
$template = get_block_template($restId, $type);
return $template && !empty($template->wp_id) ? (int) $template->wp_id : 0;
}
private static function versionToken(int $objectId, string $type): string
{
$version = ObjectVersionStore::get($type, $objectId);
return $objectId . '.' . ($version['version'] ?? 0);
}
private static function tokenFor(int $postId): string
{
if (self::$openedPostId === $postId && self::$openedToken !== null) {
return self::$openedToken;
}
return ObjectVersionStore::editorToken($postId);
}
private static function release(int $postId, string $type = 'post'): void
{
$key = $type . ':' . $postId;
if (!isset(self::$heldPostLocks[$key])) {
return;
}
ObjectMutex::release($type, $postId);
unset(self::$heldPostLocks[$key]);
}
private static function editorError(string $code, string $message): \WP_Error
{
return new \WP_Error($code, $message, ['status' => 409]);
}
/** Every post type Llamly can write through the post adapter gets the stale-editor guard. */
private static function guarded(string $postType): bool
{
return ContentGuard::isAllowedType($postType);
}
}