Skip to content

Commit ffc1534

Browse files
ifahimrezaclaude
andauthored
feat: preview transport + saddle/get-preview-url (#31)
Pillar 1's real-pixels path (#25). Saddle never renders or screenshots anything itself - it mints a signed, short-lived URL onto the site's OWN front end, and the agent's MCP client (which has a browser) does the seeing. Nothing leaves the install; the no-custody rule holds. - class-saddle-preview.php: HMAC tokens (post-bound, 5-minute TTL) over a rotating site-local secret; the previous secret stays valid through one rotation so a live token never breaks. Serving uses the proven public-preview mechanic: when the token verifies for the exact post the main query resolved, the post is flipped to publish IN MEMORY at posts_results - core's public-status enforcement runs right after, so the flip is the sanctioned hook point. Responses get X-Robots-Tag noindex, wp_robots_no_robots, and no admin bar. Registered unconditionally: an outstanding token keeps working even where minting can't. - saddle/get-preview-url (read tier): minting demands the right to SEE the content first - published posts need read access, drafts need edit rights, so a read-only viewer can never mint a window into someone's draft. - uninstall.php clears the new secret option. tests/preview-test.php (10 tests): bound+expiring mint, post-binding, forgery/expiry refusal, rotation survival, the anonymous end-to-end draft serve via go_to(), the forged-token nothing-happens path, and the ability's permission split. Free suite 291 green; Pro 119 green; phpcs clean. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e1f1ed1 commit ffc1534

5 files changed

Lines changed: 449 additions & 0 deletions

File tree

includes/abilities/render.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,28 @@ function saddle_register_render_abilities() {
5252
'meta' => saddle_ability_meta( true, false, true, 'read' ),
5353
)
5454
);
55+
56+
wp_register_ability(
57+
'saddle/get-preview-url',
58+
array(
59+
'label' => __( 'Get a preview URL', 'saddle' ),
60+
'description' => __( 'Returns a short-lived, signed URL that renders the post\'s CURRENT saved layout on the site\'s own front end — drafts included, no login needed, expires in about 5 minutes. This is how you SEE real pixels: open the URL in your own browser and screenshot it (Saddle never renders or sends anything anywhere). The page is noindex and the link opens only this one post; treat it as ephemeral and do not share it.', 'saddle' ),
61+
'category' => 'saddle',
62+
'input_schema' => array(
63+
'type' => 'object',
64+
'required' => array( 'post_id' ),
65+
'properties' => array(
66+
'post_id' => array(
67+
'type' => 'integer',
68+
'description' => __( 'The post or page to preview.', 'saddle' ),
69+
),
70+
),
71+
),
72+
'execute_callback' => array( 'Saddle_Render_Abilities', 'get_preview_url' ),
73+
'permission_callback' => Saddle_Capabilities::permission( 'read', 'read', 'get-preview-url' ),
74+
'meta' => saddle_ability_meta( true, false, false, 'read' ),
75+
)
76+
);
5577
}
5678

5779
/**
@@ -110,6 +132,39 @@ public static function render_node( $input = null ) {
110132
);
111133
}
112134

135+
/**
136+
* saddle/get-preview-url.
137+
*
138+
* A preview link exposes unpublished content to whoever holds it, so the
139+
* caller must be allowed to SEE that content first: published posts need
140+
* read access, anything else needs edit rights — a read-only viewer must
141+
* not mint a window into someone's draft.
142+
*
143+
* @param array $input Ability input.
144+
* @return array|WP_Error
145+
*/
146+
public static function get_preview_url( $input = null ) {
147+
$input = is_array( $input ) ? $input : array();
148+
$post = get_post( isset( $input['post_id'] ) ? (int) $input['post_id'] : 0 );
149+
if ( ! $post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) {
150+
return new WP_Error( 'saddle_not_found', __( 'No post or page with that ID.', 'saddle' ), array( 'status' => 404 ) );
151+
}
152+
153+
$cap = 'publish' === $post->post_status ? 'read_post' : 'edit_post';
154+
if ( ! current_user_can( $cap, $post->ID ) ) {
155+
return new WP_Error( 'saddle_forbidden', __( 'You cannot preview this post.', 'saddle' ), array( 'status' => 403 ) );
156+
}
157+
158+
$minted = Saddle_Preview::mint( $post );
159+
160+
return array(
161+
'id' => $post->ID,
162+
'url' => $minted['url'],
163+
'expires_in' => $minted['expires_in'],
164+
'note' => __( 'Open and screenshot this in YOUR browser — it renders the current saved layout (drafts included) on the site\'s own front end, is noindex, opens only this post, and expires. Do not share it.', 'saddle' ),
165+
);
166+
}
167+
113168
/**
114169
* Resolve the render accessor for a post: Gutenberg for native pages,
115170
* the `saddle_render_accessor` filter for builder pages.
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
<?php
2+
/**
3+
* Tokenized front-end previews — the safe window the agent's own client
4+
* screenshots.
5+
*
6+
* @package Saddle
7+
*/
8+
9+
defined( 'ABSPATH' ) || exit;
10+
11+
/**
12+
* Real pixels without custody (https://github.com/plugpressco/saddle/issues/25):
13+
* Saddle never renders or screenshots anything itself — it mints a signed,
14+
* short-lived URL onto the site's OWN front end, and the agent's MCP client
15+
* (which has a browser) does the seeing. Nothing leaves the install.
16+
*
17+
* The serving mechanic is the proven public-preview pattern: the URL carries
18+
* `preview=1` plus an HMAC token; when the token verifies, the main query's
19+
* post is flipped to publish IN MEMORY (core's read-cap enforcement for
20+
* non-public statuses runs after `posts_results`, so the flip is exactly the
21+
* sanctioned hook point). The token — not a login — is the access control:
22+
* post-bound, 5-minute TTL, HMAC over a rotating site-local secret. Previews
23+
* are marked noindex and never listed anywhere.
24+
*/
25+
class Saddle_Preview {
26+
27+
/**
28+
* Token lifetime, in seconds. Long enough to open and screenshot, short
29+
* enough that a leaked URL goes stale before it travels.
30+
*/
31+
const TTL = 300;
32+
33+
/**
34+
* Query arg carrying the token.
35+
*/
36+
const QUERY_ARG = 'saddle_preview';
37+
38+
/**
39+
* Option holding { secret, previous, rotated }.
40+
*/
41+
const OPTION = 'saddle_preview_secret';
42+
43+
/**
44+
* Rotate the signing secret when older than this. Verification accepts
45+
* the previous secret too, so rotation never breaks a just-minted token.
46+
*/
47+
const ROTATE_AFTER = DAY_IN_SECONDS;
48+
49+
/**
50+
* Hook the serving path. Called unconditionally from the bootstrap —
51+
* serving a token must work even when the Abilities API is absent.
52+
*/
53+
public static function register() {
54+
add_filter( 'posts_results', array( __CLASS__, 'filter_posts_results' ), 10, 2 );
55+
add_action( 'template_redirect', array( __CLASS__, 'harden_response' ), 0 );
56+
}
57+
58+
/**
59+
* Mint a preview URL for a post.
60+
*
61+
* @param WP_Post $post The post.
62+
* @return array { url, expires_in }
63+
*/
64+
public static function mint( WP_Post $post ) {
65+
$expires = time() + self::TTL;
66+
$token = $expires . '.' . self::signature( $post->ID, $expires, self::secrets()['secret'] );
67+
68+
$args = array(
69+
'preview' => 1,
70+
self::QUERY_ARG => $token,
71+
);
72+
// Hierarchical types resolve by page_id, everything else by p.
73+
if ( is_post_type_hierarchical( $post->post_type ) ) {
74+
$args['page_id'] = $post->ID;
75+
} else {
76+
$args['p'] = $post->ID;
77+
}
78+
79+
return array(
80+
'url' => add_query_arg( $args, home_url( '/' ) ),
81+
'expires_in' => self::TTL,
82+
);
83+
}
84+
85+
/**
86+
* Whether a token grants a view of a post right now.
87+
*
88+
* @param string $token Token from the URL.
89+
* @param int $post_id The post the request resolves to.
90+
* @return bool
91+
*/
92+
public static function verify( $token, $post_id ) {
93+
$parts = explode( '.', (string) $token, 2 );
94+
if ( 2 !== count( $parts ) ) {
95+
return false;
96+
}
97+
list( $expires, $signature ) = $parts;
98+
$expires = (int) $expires;
99+
if ( $expires < time() ) {
100+
return false;
101+
}
102+
103+
$secrets = self::secrets();
104+
foreach ( array( $secrets['secret'], $secrets['previous'] ) as $secret ) {
105+
if ( '' !== $secret && hash_equals( self::signature( (int) $post_id, $expires, $secret ), $signature ) ) {
106+
return true;
107+
}
108+
}
109+
return false;
110+
}
111+
112+
/**
113+
* Serve a verified preview: flip the queried post to publish in memory so
114+
* core's public-status check (which runs after this filter) lets the
115+
* site's own front end render it. The flip is bound to the exact post the
116+
* token signs — a token for one draft opens nothing else.
117+
*
118+
* @param WP_Post[] $posts Main-query results.
119+
* @param WP_Query $query The query.
120+
* @return WP_Post[]
121+
*/
122+
public static function filter_posts_results( $posts, $query ) {
123+
if ( ! self::requested() || ! $query->is_main_query() || 1 !== count( $posts ) ) {
124+
return $posts;
125+
}
126+
if ( ! $query->is_preview() || ! $query->is_singular() ) {
127+
return $posts;
128+
}
129+
130+
$post = $posts[0];
131+
if ( ! self::verify( self::requested(), $post->ID ) ) {
132+
return $posts;
133+
}
134+
135+
if ( 'publish' !== $post->post_status ) {
136+
$posts[0]->post_status = 'publish';
137+
}
138+
return $posts;
139+
}
140+
141+
/**
142+
* Mark an active preview response noindex and chrome-free.
143+
*/
144+
public static function harden_response() {
145+
if ( ! self::requested() || ! is_singular() ) {
146+
return;
147+
}
148+
// Only harden when the token actually verified for this very post —
149+
// a garbage token on a public URL is just a normal page view.
150+
if ( ! self::verify( self::requested(), get_queried_object_id() ) ) {
151+
return;
152+
}
153+
154+
if ( ! headers_sent() ) {
155+
header( 'X-Robots-Tag: noindex, nofollow' );
156+
}
157+
add_filter( 'wp_robots', 'wp_robots_no_robots' );
158+
show_admin_bar( false );
159+
}
160+
161+
/*
162+
---------------------------------------------------------------------
163+
* Internals
164+
* -------------------------------------------------------------------
165+
*/
166+
167+
/**
168+
* The raw token from the request, or '' when none.
169+
*
170+
* @return string
171+
*/
172+
private static function requested() {
173+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The HMAC token IS the verification.
174+
return isset( $_GET[ self::QUERY_ARG ] ) ? sanitize_text_field( wp_unslash( $_GET[ self::QUERY_ARG ] ) ) : '';
175+
}
176+
177+
/**
178+
* HMAC over the (post, expiry) pair.
179+
*
180+
* @param int $post_id Post id.
181+
* @param int $expires Expiry timestamp.
182+
* @param string $secret Signing secret.
183+
* @return string
184+
*/
185+
private static function signature( $post_id, $expires, $secret ) {
186+
return hash_hmac( 'sha256', $post_id . '|' . $expires, $secret );
187+
}
188+
189+
/**
190+
* The signing secrets, created on first use and rotated lazily. The
191+
* previous secret stays valid so rotation can never invalidate a token
192+
* younger than its TTL.
193+
*
194+
* @return array { secret, previous, rotated }
195+
*/
196+
private static function secrets() {
197+
$stored = get_option( self::OPTION );
198+
$stored = is_array( $stored ) ? $stored : array();
199+
200+
$secret = isset( $stored['secret'] ) && is_string( $stored['secret'] ) ? $stored['secret'] : '';
201+
$previous = isset( $stored['previous'] ) && is_string( $stored['previous'] ) ? $stored['previous'] : '';
202+
$rotated = isset( $stored['rotated'] ) ? (int) $stored['rotated'] : 0;
203+
204+
if ( '' === $secret || ( time() - $rotated ) > self::ROTATE_AFTER ) {
205+
$previous = $secret;
206+
$secret = wp_generate_password( 64, false );
207+
$rotated = time();
208+
update_option(
209+
self::OPTION,
210+
array(
211+
'secret' => $secret,
212+
'previous' => $previous,
213+
'rotated' => $rotated,
214+
),
215+
false
216+
);
217+
}
218+
219+
return array(
220+
'secret' => $secret,
221+
'previous' => $previous,
222+
'rotated' => $rotated,
223+
);
224+
}
225+
}

saddle.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
require_once SADDLE_DIR . 'includes/render/interface-saddle-render-accessor.php';
5656
require_once SADDLE_DIR . 'includes/render/class-saddle-render.php';
5757
require_once SADDLE_DIR . 'includes/render/class-saddle-render-gutenberg-accessor.php';
58+
require_once SADDLE_DIR . 'includes/preview/class-saddle-preview.php';
5859
require_once SADDLE_DIR . 'includes/class-saddle-capabilities.php';
5960
require_once SADDLE_DIR . 'includes/class-saddle-approval.php';
6061
require_once SADDLE_DIR . 'includes/class-saddle-context.php';
@@ -87,6 +88,9 @@ public static function init() {
8788
add_action( 'wp_authenticate_application_password_errors', array( 'Saddle_Connection', 'block_xmlrpc_credentials' ), 10, 3 );
8889

8990
// Always-on infrastructure (independent of the Abilities API).
91+
// The preview serving path stays up even when minting isn't — an
92+
// outstanding token must keep working for its full (short) life.
93+
Saddle_Preview::register();
9094
add_action( 'init', array( 'Saddle_Approval', 'register_cpt' ) );
9195
add_action( 'init', array( 'Saddle_Log', 'register_cpt' ) );
9296
add_action( 'init', array( 'Saddle_Skills', 'register_cpt' ) );

0 commit comments

Comments
 (0)