Skip to content

[V5] Preview-URL for headless setup #7240

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
georgobermayrsaf opened this issue May 23, 2025 · 7 comments
Open

[V5] Preview-URL for headless setup #7240

georgobermayrsaf opened this issue May 23, 2025 · 7 comments
Assignees
Milestone

Comments

@georgobermayrsaf
Copy link

Description

I try to rewire the new preview and changes views for a headless setup.

For that my idea was to overwrite the URL function in the page model:

	public function url($language = 'default'): string
	{
		$originalUrl = parent::url($language);

		$baseUrl = site()->url($language);
		$frontendUrl = $this->kirby()->option('headless.panel.frontendUrl');

		$newUrl = Str::replace($originalUrl, $baseUrl, $frontendUrl);

		return $newUrl;
	}

With that I get an Cannot produce local preview token for model error in the Panel. This comes from the previewTokenFromUrl function in Kirby\Content\Version.

The issue seems to be this comparison in the function:

		$localPrefix = $this->model->kirby()->url('base') . '/';

		// normalize homepage URLs to have a trailing slash
		// to make the following logic work with those as well
		if ($url . '/' === $localPrefix) {
			$url .= '/';
		}

		if (Str::startsWith($url, $localPrefix) === false) {
			return null;
		}

Obviously the base URL from Kirby is no longer equal with the base URL from the model. So this fails and throws the exception. If I remove the if-clause, I can see the preview in the Panel derived from my separate frontend. The preview Url als has the token attached. With that I would continue to refactor the frontend to get the Preview version from a Kirby API.

Can this comparison be removed? Or is there another/better way to create a different preview URL for headless setups?

Your setup

Kirby Version
Kirby 5 RC-1

@bastianallgeier
Copy link
Member

@lukasbestle I think you should have a closer look at this.

@lukasbestle lukasbestle self-assigned this May 23, 2025
@lukasbestle
Copy link
Member

Good point. We should definitely find a better solution. I need your help, so I'll explain where this came from and why it is needed:

This change was introduced in #6836 because with the new Changes feature, it is suddenly possible to preview changed content of already published pages (not just of drafts as in v4). This means that supporting previews of custom preview targets (controlled by the preview blueprint option) became much more relevant. Typical use case for the custom preview blueprint option is to preview the parent page that includes a section with content from its child page (with the child page redirecting to the parent and not having its own template).

For this to work with the preview tokens, we could no longer generate the tokens based on model data of the current page, but need to use data of the target URL. We came to the conclusion that the most robust way is to just use the target path. This is what the previewTokenFromUrl() method does: It uses everything after the base URL, strips away the fragment, query and params and combines that path with the version ID into a JSON object. This JSON object is then hashed/authenticated with the content.salt (which defaults to the path of the content root on the file system).

As the content.salt differs between servers (unless it is manually set to the same value), this token generation and verification will only work within the same setup. With the assumption that preview URLs outside of the base URL point to an external server, we can not reliably generate the token in these cases (the other server would not have the same salt to generate the same token for verification). I've added the "Cannot produce local preview token for model" exception so that we can spot cases more easily where this breaks. So it is good that we now have this GitHub issue this early in the RC phase.


TL;DR: IMO we first need to think about that assumption on external servers again.

In your (Georg's) use case, you control both the headless backend and frontend, so both the Panel server and the server handling the preview. So you could easily set a shared custom content.salt that would allow you to authenticate the preview on your headless frontend. If we remove the if statement and you set a shared content.salt, you will get a proper preview token that you can validate on the other end.

An issue is that in this case the uri value inside that hashed JSON object will need to be the full preview URL, not just the path (as we cannot know which part of the external preview URL is the base URL and which part is the path, e.g. in subfolder setups).

What we could probably do is to just always use the full URL and not the path. This could slightly reduce reliability if the hostname is detected differently on the preview request than when generating the preview URL. But in most setups it should work fine.

@lukasbestle lukasbestle added this to the 5.0.0 milestone May 23, 2025
@silllli
Copy link
Contributor

silllli commented May 25, 2025

Not sure if I fully understand the problem, but this is how I do it or did handle previews in my headless Kirby setup:

I use Kirby’s regular templates to return JSON from them. I've added a pageMethod that creates translated URIs for me, since Kirby doesn’t provide these (only absolute URLs or non-translated IDs). I pass these to the frontend which uses relative URLs for navigation.

Then I have a pageMethod that will return the absolute preview URL to be used in blueprints, which is based on a frontendUrl config option, just like @georgobermayrsaf has one, and my URI method.

Since my getUri method is based on the page's uri method, the preview token was automatically added and therefore available in the blueprint, which uses my getPreviewUrl helper.

The frontend is called with the preview token, which it then adds to its fetch request. Just like with regular templates, the preview data instead of the regular data was returned.

With version 5, no token is added to my preview URL and therefore previews stopped working. Let me know if I can help somehow … in the meantime I try to understand the problem better. 😀

@georgobermayrsaf
Copy link
Author

Thank you all for the feedback!

@lukasbestle , I think the solution you propose would be fine from my end. I can easily add a content.salt config that is controlled via an .env value.

I'm not sure if I need to involve the frontend here tough. Maybe I'm missing something. As Kirby is creating the preview URL, e.g. http://localhost:3000/my-page/?_token=a7c46e8f87&_version=changes the frontend only has to work on the existence of the _token and version value of the request queries. If this is existing, I will send a KQL query to the backend to fetch the data. For that I implemented a new query type like that:

Query::$entries['preview'] = function (string $pageUid, string $token, string $version): Page|null {
	if (!$token || !$version) {
		throw new Exception('Not all required parameters are set');
	}

	$page = page($pageUid);
	if (!$page) {
		throw new Exception('Page not found');
	}

	$version = $page->version($version);
	if (!$version->exists()) {
		$version = $page->version('latest');
	}

	$expectedToken = $version->previewToken();

	if (hash_equals($expectedToken, $token) !== true) {
		throw new Exception('Invalid token');
	}

	// I'm not sure if this is the most elegant way to do that. I couldn't find a method that returns a page object from the version. 
	$versionPage = Page::factory([
		'slug' => $page->slug(),
		'content' => $version->content()->toArray(),
		'template' => $page->intendedTemplate()->name(),
		'model' => $page->intendedTemplate()->name(),
	]);

	return $versionPage;
};

The frontend is doing a KQL query like preview('my-page', 'a7c46e8f87', 'changes'). This works all very well. So the token generation and validation remains on the backend?

But as I said, maybe I'm missing something here. And for sure: If needed I can set a content.salt value in both backend and frontend.

@silllli : The missing preview token was the reason for me to start a refactoring. Overwriting the url() function for seemed to be the most approachable way to stay close to the core and not establish to many new concepts.

Just as a side note: I really like what you guys did with versions and changes! Thie UI is very nice and the architecture and APIs seem to be very good to work with. I was able to remove a lot of custom code I needed to have for headless preview and our live-preview storybuilder. Kudos to that!

@silllli
Copy link
Contributor

silllli commented May 26, 2025

@georgobermayrsaf Yep, that’s also the part that confuses me. Until now the backend got its authentication in form of the token and responded with the otherwise inaccessible content (HTML, or in my case JSON), although the token now would have to include the desired version. That would be the most comfortable solution from my view. 😌

@lukasbestle
Copy link
Member

Thank you both for your input. I think we'll try the solution I drafted in my last post and see if that again has any disadvantages in the real world. I think it should be pretty robust and is probably the only way to support external preview URLs without complex logic.


A few off-topic replies and ideas for your setups:

@silllli Since the new token implementation hooks into $page->url(), you have two options: Either you overwrite $page->url() like Georg does it. Or you can update your getPreviewUrl page method to call $page->version()->previewToken(). This is a method marked as @internal though, so it may change in the future.

@georgobermayrsaf:

  • You are right, if you pass the token in the KQL request, your frontend server does not need to validate the token by itself, so it does not need to have a shared content.salt.
  • About a more elegant way to return the correct content version: You could try to set VersionId::$render = $version->id() before returning the simple $page object. Not sure if that has the intended effect in the KQL context, but this is the way Kirby does it internally when it renders a particular version.

@lukasbestle
Copy link
Member

lukasbestle commented May 26, 2025

I have a PR to fix this almost ready, but I'm struggling with blinds unit tests. Tomorrow is another day.

Peter Griffin from the Family Guy TV series struggling with blinds

@bastianallgeier bastianallgeier modified the milestones: 5.0.0-rc.2, 5.0.0 May 27, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

No branches or pull requests

4 participants