WordPress preview support

OPENING POST


Description

We’d like to support the WordPress preview functionality for both the decoupled and the embedded mode, so it the CMS experience remains the same.

Possible solution

We could use the Embedded mode to support the preview in the decoupled. We’d need a WordPress plugin for that. This was our first idea:

Open Image

The JWT generated contains information in its payload about:

  • The expiring time
  • The post ID

The expiring time for the normal preview is 60 seconds, enough time to send the request to Frontity and Frontity send the request back to the REST API. After those 60 seconds it is not valid anymore.

A new token is generated for each request (each time the user clicks on the Preview button). That is because each time the expiring time changes, that means that the payload is different and the token is different.

The expering time for the publicly sharable link is infinite. That means that the token is always the same. To avoid having to save token in the database, just a post meta "public-share" setting is saved. The non-expiring token is only valid if that post meta is true. Disabling the sharable link simply turns the "public-share" meta to false.

The secret key used is a constant that the user needs to define in wp-config.php named PREVIEW_AUTH_KEY but it defaults to SECURE_AUTH_KEY if missing.


SUMMARY


Please, bear in mind that this section wasn’t part of the opening post. It has been added afterwards and its purpose is to keep a quick summary with the most relevant information about this FD at the top of the thread.

Relevant information

We are going to do a proof of concept of the preview in our next sprint. This is the proposal:

Dev - Preview mode PoC overview

If this approach works well, we will do a proper implementation proposal.

1 Like

I started with the research, and the first thing I tried to solve was how to allow a REST API request to get revisions from a specific post, mocking the content of a JWT.

My first approach was to use the rest_authenticate_errors filter hook to check the JWT and authenticate somehow but that didn’t work because we don’t want authentication but authorization as the JWT won’t be associated to any WordPress user (correct me here if I’m wrong).

Later I found a filter called user_has_cap that allows WordPress to modify capabilities for a user during runtime, and I think it is a good solution. With that filter, you can allow any user you want (even the anonymous user if there’s no user authenticated) to access specific entities without any other check (I wrote the following code in my local environment and it works fine!). Only the entity specified in the mocked JWT can be fetched.

/**
 * Modify user capabilities on run time.
 */
add_filter( 'user_has_cap', function ( $allcaps, $caps, $args, $user ) {
  // Simulate the content of a JWT.
  $jwt = array(
    // Allow only GET requests so nothing can be modified or deleted.
    'allow_methods' => array( 'GET' ),
    // Capabilities needed to get revisions from the REST API.
    'capabilities' => array( 'edit_post', 'delete_post' ),
    // Post ID from which we want to get revisions.
    'post_id' => 2003
  );

  // REST API check.
  $is_rest_request = defined( 'REST_REQUEST' ) && REST_REQUEST;

  // This is not a REST API request so do not change capabilities.
  if ( ! $is_rest_request ) return $allcaps;
  
  // TOKEN CHECKS.

  // If it is not an allowed HTTP method do not change capabilities.
  if ( ! in_array( $_SERVER[ 'REQUEST_METHOD' ], $jwt['allow_methods'] ) ) {
    return $allcaps;
  }

  // If the capability being check doesn't match do not change capabilities.
  if (! in_array( $args[0], $jwt['capabilities'] ) ) {
    return $allcaps;
  }

  // If it is not the post ID do not change capabilities.
  if ( $args[2] !== $jwt['post_id'] ) {
    return $allcaps;
  }

  // Add capabilities.
  foreach ( $caps as $cap ) {
    $allcaps[ $cap ] = true;
  }

  // Return capabilities.
  return $allcaps;
}, 9999, 4);
2 Likes

Nice!

Could we add a more strict limit, to change the capabilities only if its a revisions request? We don’t want to open the door to any other thing.

WordPress Preview - Proof of Concept

Plugin: https://github.com/frontity/frontity-embedded-proof-of-concept/tree/preview-poc
Frontity: https://github.com/frontity/frontity/tree/preview-poc

Using the idea I mentioned before, I modified the Frontity Embedded Mode - [Proof of Concept] in a way it renders the template.php file also for previews, sending a token to Frontity in order to get the latest review.

Right now, that token is generated using the PHP-JWT library, only last 60 seconds and contains information to allow any request – using that token in an Authorization: Bearer header – to only make HTTP GET requests with extended capabilities for a specific post or page (edit_post, delete_post). Those capabilities are needed when getting revisions from the REST API, not only for modifying them, but I think it is secure enough as we only allow HTTP GET requests by our implementation.

Regarding Frontity, I didn’t work on a standard way to store and handle tokens, I simply modified the postType handler to look for a token param in the link being fetched and, if it is found, fetches the latest revision using that token. When the response is received it replaces the content, title, and excerpt properties from the post by the received ones.

A thing that is not clear to me is where is the best place to generate tokens. At first, I thought of generating them using the preview_post_link hook but that would make the expiration countdown to start once you enter to the edit page (you may spend more than 60 seconds editing a post, right?). So, I decided to generate tokens when accessing a preview link (i.e. https://test.frontity.org/2016/the-beauties-of-gullfoss?preview=true&preview_id=60), but only when a user is logged in, preventing anonymous users to get preview tokens. Maybe there is a better way to do this so ideas are welcomed. :slightly_smiling_face:


I guess we can check other things as well, sure. It only depends on our implementation. I used the Roles and Capabilities system WordPress already have for this but I didn’t take a deeper look into it yet. I also tried not to add user information in the tokens on purpose, but that’s another thing we can change if we find it appropriate.

5 Likes

This is great David :clap:! I’ve tested it in my local environment and it works nicely :slightly_smiling_face: It works exactly like WordPress, you’re redirected to the Frontity preview after clicking the button but the original post remains the same. You even keep the admin bar. Here you have a quick demo of the proof of concept:

5 Likes

Just a few things that I think are worth mentioning:

  • My proof of concept doesn’t work with posts or pages that were not published yet because the preview links have this format: http://frontity.local/?p=2115&preview=true

    We will have to modify the handler for the root path in order to recognize that kind of link.

  • I said that tokens last one minute (60 seconds) but they actually last 10 minutes long. I changed it for testing purposes and forgot to change it back. :sweat_smile:

Yes, that was my idea as well. That link needs to work always, not matter if I just edited the content or it was three days ago. For that, it needs to generate a new token each time it’s visited.

@david do you have any ideas for this? Maybe introducing a new handler that supports /?p=ID type of URLs?

Not sure if it helps but if you are authenticated and you fetch the post X endpoint (not revisions) when it hasn’t been published yet, you could get the content for the preview. You even have the featured image or new categories, so it’d be like a common post.

1 Like

I’ve opened a new Feature Discussion to talk about that: Support for custom headers in Source packages.

I’m gonna try adding the queries to the getMatch, the function that does the matching between the links and the patterns.

@david, I don’t like that we rely on post._links["predecessor-version"] to fetch the preview post.

I analyzed a big REST API response and half of its size was due to the _links so removing that field means half the size of the response. If we rely on _links for this, people would not be able to remove them.

Do you know if there is a case where http://embedded.local/wp-json/wp/v2/posts/1/revisions/?per_page=1 is not good enough?


I also would love to know your opinion on Support for custom headers in Source packages :slightly_smiling_face:

Ok, doing so with /revisions/?per_page=1 seems fine as WordPress seems to be getting the always the last revision.

This is the code that generates the _links["predecessor-version"] link.

$revisions = wp_get_post_revisions( $post->ID, array( 'fields' => 'ids' ) );
$last_revision = array_shift( $revisions );

$links['predecessor-version'] = array(
  'href' => rest_url( trailingslashit( $base ) . $post->ID . '/revisions/' . $last_revision ),
  'id'   => $last_revision,
);

Source: https://github.com/WordPress/wordpress-develop/blob/master/src/wp-includes/rest-api/endpoints/class-wp-rest-posts-controller.php#L1885-L1902

Wow, it is actually the very same as /wp-json/wp/v2/posts/1/revisions/?per_page=1. You can replace it with the other call, of course!

Sure, I’ll give my feedback in the other thread. :+1:

I’ve managed to get a solution that may work to support query handlers in the current version of Source v1, but still requires more research.

This is the (almost) working PR. It also includes a handler for ?p=ID URLs: https://github.com/frontity/frontity/commit/aadd640ae41c96695f2c1c14aa8c9015ee610d66

The solution proposed is to send both route (which is the link without the /page/x part) along with the query to the getMatch function, but only match the query for handlers that have a flag, for example, matchQuery: true.

The only thing I’ve not managed to figure out is how to set isHome. I’ll work on that and I’ll try to research the implications of adding this system to the current version of source.

So, right now the data.isHome flag is set by fetch instead of the handlers. The logic is:

const isHome = route === normalize(state.source.subdirectory || "/");

Code: https://github.com/frontity/frontity/blob/dev/packages/wp-source/src/actions.ts#L71

The problem is that /?p=ID is not the home, but /?utm_campaign=sale is. We need to be able to differentiate between them.

I see two solutions for the isHome problem:

  1. Add a whitelist of queries that are used in URLs to distinguish between.
  2. Move the logic to the handlers.

Sadly, the second one is not backward-compatible because any person who has a custom handler for the home and it’s relying on data.isHome in his/her theme will be affected if we remove it.


Regarding handlers, I’m thinking about these two options:

  1. Add support for preview to the postType handlers and add a new hander for ?p=ID.
  2. Add a new handler for ?preview=true that handles both cases: slug (/some-post) and query (?p=ID).

Both cases have the isHome problem.

I’ve kept working on this. This my last idea:

path-to-regexp patterns are too limited for queries, so I think we can add support for regular expressions if you want to match queries.

  • If you want to match a URL that doesn’t include a query, you use a path-to-regexp pattern:

    pattern: "/category/:slug" to match /category/nature.

  • If you want to match a URL that includes a query, you use a regular expression:

    pattern: "RegExp:(\\?|&)p=\\d+" to match /?p=13 or /?...&=p=13.

Thanks that, we can also differenciate if the URL is from the home or not, maintaining the backward-compatibility:

const isHome =
  !handler.pattern.startsWith("RegExp:") &&
  route === normalize(state.source.subdirectory || "/");
2 Likes

The work in the PR is almost done: https://github.com/frontity/frontity/pull/564

@david is taking care of the last tasks.


As I mentioned in the PR, I think we should change the name of the token. token is too general. It should start at least with frontity_ to avoid collisions and include more information to allow for other types of tokens and versions.

I’m thinking:

  • Starts with frontity because all Frontity related queries should start like that.
  • It’s followed by preview because that its purpose.
  • It’s followed by jwt because that’s the implementation.
  • It ends with a version, in case we need to increase it at some point while maintaining the old implementation.

So something like this: frontity_preview_jwt_token_v1.

Opinions?

1 Like

I like this approach and it seems something we could use for future implementations similar to this one.