Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ The `docs/` directory contains **what** applies to this project:
- `docs/deployment.md` - Hosting provider and deployment rules
- `docs/releasing.md` - Version scheme and release process
- `docs/sitemap.md` - XML sitemap module, coverage and generation
- `docs/preview-links.md` - sharing unpublished content by link
- `docs/faqs.md` - Project-specific FAQs

**Always check these files first** to understand project-specific decisions.
Expand Down
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"drupal/moderated_content_bulk_publish": "^2.0",
"drupal/navigation_extra_tools": "^1.3.2",
"drupal/pathauto": "^1.15",
"drupal/preview_link": "^2.2.1",
"drupal/purge": "^3.7",
"drupal/purge_control": "^2.1",
"drupal/recaptcha_v3": "^2.0.5",
Expand Down
76 changes: 75 additions & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions config/default/core.extension.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ module:
paragraphs_library: 0
path: 0
path_alias: 0
preview_link: 0
purge: 0
purge_drush: 0
purge_processor_cron: 0
Expand Down
1 change: 1 addition & 0 deletions config/default/entity_clone.cloneable_entities.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,4 @@ cloneable_entities:
- ai_prompt_type
- ai_prompt
- xmlsitemap
- preview_link
7 changes: 7 additions & 0 deletions config/default/preview_link.settings.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
_core:
default_config_hash: zsRG48Wp1H1fGnUoDCdsnWJMnrrwtZPwGLvGYuvKCNU
display_message: subsequent
enabled_entity_types:
node: { }
multiple_entities: true
expiry_seconds: 1209600
2 changes: 2 additions & 0 deletions config/default/user.role.civictheme_content_approver.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ dependencies:
- moderated_content_bulk_publish
- navigation
- node
- preview_link
- scheduled_transitions
- system
_core:
Expand All @@ -33,6 +34,7 @@ permissions:
- 'administer linkit profiles'
- 'administer scheduled transitions'
- 'generate ai alt tags'
- 'generate preview links'
- 'moderated content bulk archive'
- 'moderated content bulk publish'
- 'moderated content bulk unpublish'
Expand Down
2 changes: 2 additions & 0 deletions config/default/user.role.civictheme_content_author.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies:
- navigation
- node
- path
- preview_link
- scheduled_transitions
- system
- taxonomy
Expand Down Expand Up @@ -119,6 +120,7 @@ permissions:
- 'edit own project content'
- 'edit terms in civictheme_media_tags'
- 'generate ai alt tags'
- 'generate preview links'
- 'revert all revisions'
- 'revert blog revisions'
- 'revert civictheme_alert revisions'
Expand Down
3 changes: 3 additions & 0 deletions config/default/user.role.civictheme_site_administrator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ dependencies:
- paragraphs_library
- path
- pathauto
- preview_link
- redirect
- scheduled_transitions
- system
Expand Down Expand Up @@ -81,6 +82,7 @@ permissions:
- 'administer menu'
- 'administer nodes'
- 'administer paragraphs library'
- 'administer preview link settings'
- 'administer redirects'
- 'administer scheduled transitions'
- 'administer taxonomy'
Expand Down Expand Up @@ -199,6 +201,7 @@ permissions:
- 'edit webform twig'
- 'edit webform variants'
- 'generate ai alt tags'
- 'generate preview links'
- 'link to any page'
- 'moderated content bulk archive'
- 'moderated content bulk publish'
Expand Down
44 changes: 44 additions & 0 deletions docs/preview-links.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Preview links

Editors share unpublished content with people who have no account on the site using [`drupal/preview_link`](https://www.drupal.org/project/preview_link). Generating a link produces a tokenised URL that anyone can open, so a reviewer needs neither an account nor a permission.

## Why this module

The editorial workflow keeps `draft` and `needs_review` off the default revision, so a page that is already live can carry a pending draft that no canonical URL will ever render. `preview_link` grants access to the **latest** revision, which covers both a page that has never been published and a draft sitting behind a published one.

The obvious alternative, [`drupal/access_unpublished`](https://www.drupal.org/project/access_unpublished), only unlocks entities whose default revision is unpublished. It cannot reveal a pending draft, which is half the cases here.

## How editors use it

1. Open the content item and choose the **Preview Link** tab (`/node/<nid>/generate-preview-link`).
2. Copy the generated URL and send it on.
3. **Save and regenerate preview link** mints a new token and immediately kills the old URL. **Reset lifetime** restarts the clock without changing the URL.

Opening the link binds its token to the visitor's session, so from that point they can follow ordinary links into the content: a URL they could not otherwise see redirects them to its preview, carrying a notice that explains why and offers to drop the token.

## What is configured

| Setting | Value | Why |
|---|---|---|
| `enabled_entity_types` | `node`, no bundle list | An empty bundle list means every content type, so a type added later gets preview links without a config change. |
| `expiry_seconds` | `1209600` (14 days) | Covers a review that spans a couple of weeks, including a reviewer who is away for one of them, while still bounding how long unpublished content stays reachable. |
| `multiple_entities` | `true` | A page is assembled from paragraphs and media, which have to travel with the node for the preview to render like the published page will. |
| `display_message` | `subsequent` | Shows the recipient why they can see the page, and offers to drop the token, but only when they arrive by being redirected from a normal URL. Landing on the preview link itself says nothing, which keeps the first thing they see the content rather than a notice. |

`generate preview links` is granted to Content Author, Content Approver and Site Administrator. `administer preview link settings` is granted to Site Administrator only. Recipients need no permission at all - the token is the entire credential, which is why neither permission belongs on the anonymous or authenticated role.

## Keeping previews contained

Both act on any route flagged `_preview_link_route`, and both cover gaps the module leaves open:

- `_do_base_attach_preview_link_robots()` emits `noindex, nofollow`, because a preview URL is meant to be pasted into mail and chat clients that follow links.
- `PreviewLinkCacheSubscriber` sends `Cache-Control: no-store` and trips the page cache kill switch. Left alone the response is `max-age=900, public`, and merely starting a session would only downgrade it to `private` - enough to bar shared caches, but the recipient's own browser could still surface the content from history once the link had expired or been regenerated.

## Expiry

Expired links are deleted on cron. Expiry is enforced on access, so a link stops working the moment it lapses rather than when cron next runs.

## Related

- [Development agreements](development.md) - function visibility conventions the hook follows
- [Testing](testing.md) - PHPUnit and Behat conventions
44 changes: 44 additions & 0 deletions tests/behat/features/preview_link.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
@p1 @preview_link
Feature: Preview links for unpublished content

As a content editor
I want to send someone a link to content that is not published yet
So that they can review it without needing an account on the site

Background:
Given the following "civictheme_page" content:
| title | moderation_state | field_c_n_summary |
| [TEST] Preview Draft Page | draft | [TEST] Draft page summary |

@api
Scenario: Content Author generates a preview link for a draft page
Given I am logged in as a user with the "Content Author" role
When I visit the "civictheme_page" content page with the title "[TEST] Preview Draft Page"
And I click "Preview Link"
Then the response status code should be 200
And I should see "Preview link"

When I press "Save and regenerate preview link"
Then the response status code should be 200
And I should see "Expiry:"
And the response should contain "/preview-link/node/"

@api
Scenario: Site Administrator can reach the preview link form for a draft page
Given I am logged in as a user with the "Site Administrator" role
When I visit the "civictheme_page" content page with the title "[TEST] Preview Draft Page"
And I click "Preview Link"
Then the response status code should be 200
And I should see the button "Save and regenerate preview link"

@api
Scenario: Editor without the permission is not offered a preview link
Given I am logged in as a user with the "access content, access administration pages, access content overview, view any unpublished content" permissions
When I visit the "civictheme_page" content page with the title "[TEST] Preview Draft Page"
Then I should not see the link "Preview Link"

@api
Scenario: Site visitor cannot reach a draft page without a preview link
Given I am an anonymous user
When I visit the "civictheme_page" content page with the title "[TEST] Preview Draft Page"
Then the response status code should be 403
40 changes: 35 additions & 5 deletions web/modules/custom/do_base/do_base.module
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,45 @@ function do_base_mail_alter(array &$message): void {
* Implements hook_page_attachments().
*/
function do_base_page_attachments(array &$attachments): void {
// Attach a CSP nonce to script-src on every page so that Drupal core's
// inline scripts (BigPipe placeholders, drupalSettings, etc.) continue to
// run under a strict Content-Security-Policy. The fallback 'unsafe-inline'
// is only used by browsers that do not support CSP3 nonces; modern
// browsers ignore it when a nonce is present.
_do_base_attach_preview_link_robots($attachments);
_do_base_attach_csp_nonce($attachments);
}

/**
* Keeps preview link pages out of search indexes.
*/
function _do_base_attach_preview_link_robots(array &$attachments): void {
$route = \Drupal::routeMatch()->getRouteObject();

if ($route === NULL || $route->getOption('_preview_link_route') !== TRUE) {
return;
}

// A preview link renders unpublished content to anyone holding the token,
// and the URL is meant to be pasted into mail and chat clients that follow
// links, so the page must never reach a search index.
$attachments['#attached']['html_head'][] = [
[
'#tag' => 'meta',
'#attributes' => [
'name' => 'robots',
'content' => 'noindex, nofollow',
],
],
'do_base_preview_link_robots',
];
}

/**
* Attaches a CSP nonce so core's inline scripts survive a strict policy.
*/
function _do_base_attach_csp_nonce(array &$attachments): void {
if (!class_exists(Csp::class)) {
return;
}

// The 'unsafe-inline' fallback is only used by browsers without CSP3 nonce
// support; modern browsers ignore it once a nonce is present.
$existing = $attachments['#attached']['csp_nonce']['script'] ?? [];
$attachments['#attached']['csp_nonce']['script'] = array_values(array_unique(array_merge($existing, [Csp::POLICY_UNSAFE_INLINE])));

Expand Down
5 changes: 5 additions & 0 deletions web/modules/custom/do_base/do_base.services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,8 @@ services:
arguments: ['@class_resolver', '@theme_handler']
tags:
- { name: event_subscriber }
do_base.preview_link_cache_subscriber:
class: Drupal\do_base\EventSubscriber\PreviewLinkCacheSubscriber
arguments: ['@page_cache_kill_switch']
tags:
- { name: event_subscriber }
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<?php

declare(strict_types=1);

namespace Drupal\do_base\EventSubscriber;

use Drupal\Core\PageCache\ResponsePolicy\KillSwitch;
use Drupal\Core\Routing\RouteObjectInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\Route;

/**
* Keeps preview link responses out of every cache.
*
* A preview link shows unpublished content to whoever holds its token, and that
* token can expire or be regenerated at any moment. Any stored copy outlives
* it: a shared cache would go on serving the content to whoever asks for that
* URL, and a browser cache would surface it from history once the token had
* stopped working.
*/
final class PreviewLinkCacheSubscriber implements EventSubscriberInterface {

public function __construct(
protected KillSwitch $killSwitch,
) {}

/**
* Marks a preview link response as never storable.
*/
public function onResponse(ResponseEvent $event): void {
if (!$event->isMainRequest()) {
return;
}

$route = $event->getRequest()->attributes->get(RouteObjectInterface::ROUTE_OBJECT);

if (!$route instanceof Route || $route->getOption('_preview_link_route') !== TRUE) {
return;
}

// The internal page cache decides from the response policy rather than
// from the header, so setting the header alone would not stop it storing
// the page.
$this->killSwitch->trigger();

// Rendering the page starts a session, which on its own only earns the
// response 'private'. That bars shared caches but still lets the
// recipient's own browser keep a copy, so ask for no storage at all.
$event->getResponse()->headers->set('Cache-Control', 'no-store');
}

/**
* {@inheritdoc}
*/
public static function getSubscribedEvents(): array {
// Later than FinishResponseSubscriber, which sets Cache-Control itself.
return [KernelEvents::RESPONSE => ['onResponse', -10]];
}

}
Loading
Loading