Skip to content

Show or hide close button based on destination parameter #109

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
wants to merge 10 commits into
base: main
Choose a base branch
from

Conversation

donquixote
Copy link
Collaborator

No description provided.

@@ -41,7 +41,7 @@ function collabora_online_theme(): array {
'accessToken' => 'test',
'accessTokenTtl' => '86400',
'iFrameStyle' => 'width:95%;',
'closebutton' => '',
'closeButtonUrl' => '',
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The goal here was to keep the changes to template and js to a minimum.
We should rethink this in a follow-up.

@@ -108,11 +108,14 @@ function collabora_online_entity_operation(EntityInterface $entity): array {
return [];
}

$options = [];
$options['query'] = \Drupal::destination()->getAsArray();

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I looked for examples in core how they populate the 'destination' parameter.
I found only one case where they explicitly use the current url, in other cases they use that destination service.

The main difference is this (I think): If the current page already has a destination parameter, it will be kept for the link.

@@ -25,7 +25,7 @@ function postReady() {
});
}

function receiveMessage(hasCloseButton, event) {
function receiveMessage(close_button_url, event) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use lowerCamelCase here, for consistency with the rest of the script.
In the end we should redo all of it.

*
* @see \Drupal\Core\Form\ConfirmFormHelper::buildCancelLink()
* @see \Drupal\Core\EventSubscriber\RedirectResponseSubscriber::getDestinationAsAbsoluteUrl()
*/
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would have been more straightforward to simply read the destination parameter with javascript. However, I have not found this done in Drupal core.
Reading the parameter in php makes sure that we apply the same processing as in other places.

This said, I did look for other places that parse the destination parameter, and it does not look very standardized.

if (closebutton == 'true') {
options = { closebutton: true };
}
const close_button_url = {{ closeButtonUrl|json_encode|raw }};
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the most "correct" way to escape and quote values for js.
An alternative is '{{ varname|e('js') }}', but this also escapes ':' because it is meant for a broader purpose.
Originally we are just doing "{{ varname }}" but this is wrong, e.g. "{{ '"' }}" leads to broken js.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I restored

const close_button_url = '{{ closeButtonUrl|e('js') }}';

It does indeed get escaped into

// This is defined in the js/cool.js
const close_button_url = 'http\u003A\/\/web.test\u003A8080\/admin\/content\/media';

But it works just fine on the redirect?
image

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, you can verify with js console: 'http\u003A\/\/' === 'http://' is true.

The |e('js') is more noisy because it is designed for a wider range of scenarios.

Also, you need to remember that you must use double quotes "{{ var|e('js') }}", to support all possible strings.
In this case we usually won't have double quotes in the url, but better to always do the right thing.

I still prefer |json_encode|raw, it relies on logic instead of how a specific twig feature behaves.

@donquixote donquixote force-pushed the close-button-destination branch 3 times, most recently from 1d4ee8b to f3c190d Compare May 28, 2025 10:55
@donquixote donquixote force-pushed the close-button-destination branch from f3c190d to 58f12bc Compare May 28, 2025 11:43
Comment on lines +145 to +164
protected function getDestinationUrl(Request $request): ?Url {
$destination = (string) $request->query->get('destination');
if (
!$destination ||
UrlHelper::isExternal($destination) ||
// Instead of ltrim(), simply discard a destination with missing or
// unexpected leading slashes.
!str_starts_with($destination, '/') ||
str_starts_with($destination, '//')
) {
return NULL;
}
$parsed = UrlHelper::parse($destination);
$options = [
'query' => $parsed['query'],
'fragment' => $parsed['fragment'],
'absolute' => TRUE,
];
return Url::fromUserInput($parsed['path'], $options);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This approach seems too defensive. There is similar code in core, but for legacy urls. I don't care about legacy in our code.
Let's just use the destination service:

try {
      @see \Drupal\Core\Routing\RedirectDestinationInterface::get
      $destination_url = \Drupal\Core\Url::fromUserInput(\Drupal::destination()->get())->setAbsolute();
    }
    catch (\InvalidArgumentException) {
      $destination_url = NULL;
    }

We are not here to provide all the possible scenarios when users mingle with the redirect url. What we care is only the security part.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By using the destination service, we get a value (and thus a close button) even if the query parameter is empty.
This is because Destination falls back to the current url.

It seems to me that:

  • The destination service is meant for code that wants to add a destination parameter to a url.
  • The actual redirect is done by other code such as RedirectResponseSubscriber, which does not use this service, and which I am replicating here more or less.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

By using the destination service, we get a value (and thus a close button) even if the query parameter is empty.
This is because Destination falls back to the current url.

(and a failing test)

Comment on lines 118 to 123
* @testWith ["view", "/admin/structure"]
* ["edit", "/admin/structure"]
* ["edit", "/non/existing/path?x=y"]
* ["edit", "malformed/path", false]
* ["edit", "//multislash/path", false]
* ["edit", "https://example.com/hello", false]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We said already that data providers eat time in these non unit test cases. Let's avoid this. How can we avoid the install loop?

Furthermore let's test only the exception case (no destination), external case (fallbacks to /) and one working one.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keep in mind this is ExistingSite, so no install loop.
The data providers are probably not much different from calling multiple cases from the same test method.
The duration is then from opening the iframe and loading the editor.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed even with ExistingSite, by combining all cases into one, for me the time is reduced from 15s to 5s.
One reason is that we don't have to repeat the create user, login, and create document media.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Furthermore let's test only the exception case (no destination), external case (fallbacks to /) and one working one.

We do want to cover preview vs edit:

  • The UI looks a bit different, and the close button is placed differently. (this is a bit of a canary test, telling us if something changes in CollaboraOnline)
  • In our own code, the logic that determines close button or not also determines preview vs edit.

I would also like to cover malformed urls, but ok I removed it.

Comment on lines +172 to +186
// Repeat the click until the button disappears.
$this->getSession()->getPage()->waitFor(10000, function () use ($close_button, &$n) {
// On successful click, the user is redirected to the destination url.
// As a consequence, the close button disappears, and subsequent calls
// to $close_button->click() trigger a NoSuchElement exception.
// By relying on this exception, we avoid any kind of race condition that
// could lead to rare random test failures.
try {
$close_button->click();
}
catch (NoSuchElement) {
return TRUE;
}
return FALSE;
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Odd, let's remove it. We click once, we need to be redirected.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not working for me, unless I wait e.g. 200ms before clicking the button.
I tried to find conditions to check whether the button is clickable, but any condition I could find (including post message telling us we are ready) is already true when this happens.

if (closebutton == 'true') {
options = { closebutton: true };
}
const close_button_url = {{ closeButtonUrl|json_encode|raw }};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I restored

const close_button_url = '{{ closeButtonUrl|e('js') }}';

It does indeed get escaped into

// This is defined in the js/cool.js
const close_button_url = 'http\u003A\/\/web.test\u003A8080\/admin\/content\/media';

But it works just fine on the redirect?
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants