Other than that, there are a couple of minor things to watch out for. For example, when you take a screenshot of the site whilst logged in you often get your profile picture included in the image. Not a big problem, but I’ve gotten used to opening an incognito window and taking the screenshot from there if I happen to be logged in at the time.As we are accepting data this REST resource needs to accept only POST requests, which adds a little bit of complexity to the setup of the system. In order to accept an incoming POST request Drupal must be able to authenticate you as a user, which can be done in a number of different ways.The authentication mechanism is set up secondarily to the REST plugin, so let’s put that together first.The included script.js file contains all of the event listeners for the extension to react to user input. I won’t show them all here, but the main one is listening to the click event on the “Grab Link Data” button. When the user clicks this button the script will pick up the current tab, use the service worker to take a screenshot of the page (with set dimensions), collect any information about the page we can, and present the results to the user.The only niggle was that I needed the screenshot to be at a set dimension, since all the link images on the site also have that dimension. That turned out to be slightly more challenging.One slight niggle before we can move on is bypassing the security provided by the SecKit module on the site. This module is used to add various security hardening options to Drupal, mitigating a number of different vulnerabilities. The main protection mechanism we are concerned about here is the Cross-site Request Forgery (CSRF) protection, which will look for the Origin header and reject the request if it isn’t recognised as being from a set number of websites.
Creating A REST Resource
If you want to look at the full source code of the Link It! extension then you can find it on CodeBerg. You won’t be able to push anything to the site, but it shows how to create an extension, create an options page for that extension, and take a screenshot of the currently active tab.{
"name": "Link Directory Link It",
"version": "1.0.0",
"description": "Takes screenshots for the link directory.",
"manifest_version": 3,
"author": "Philip Norton",
"permissions": ["debugger", "tabs", "activeTab", "storage"],
"action":{
"default_popup": "index.html",
"default_title": "Link it!"
},
"background": {
"service_worker": "background.js"
},
"host_permissions": [
"https://www.hashbangcode.com/*",
"https://hashbangcode.ddev.site/*"
],
"options_page": "options.html"
}
When we make a request from a Chrome extension it will come from a chrome:// address, with a unique ID that is different for every browser that the extension is installed into. Clearly, we can’t keep on top of updating the allowed list every time we install the extension, so we need another mechanism to bypass this security layer, but still have it active.class LinkDirectorySecKitEventSubscriber extends SecKitEventSubscriber {
public function seckitOrigin($event) {
$hashbangcodeExtension = $this->request->headers->get('Hashbangcode-Extension');
if ($hashbangcodeExtension === 'link-it') {
$pathInfo = $this->request->getPathInfo();
if ($pathInfo == '/session/token' || $pathInfo == '/api/links/[REDACTED]') {
// Allow the request if it had the correct header and is pointed
// at the correct paths.
$this->request->headers->set('Origin', 'null');
return;
}
}
parent::seckitOrigin($event);
}
}
The way around this is to decorate the DrupalseckitEventSubscriberSecKitEventSubscriber event subscriber from the SecKit module so that we can look for a request from the extension and allow it to proceed. To decorate a service we just need to create a service in our modules services.yml file and add the “decorates” directive, pointing to the service we want to decorate.When we click the button in Chrome we are shown this dialog, with some of the options hidden from view until they are needed.Here is an example of the problem.
const tokenUrl = 'https://www.hashbangcode.com/session/token';
const linkCreateUrl = 'https://www.hashbangcode.com/api/links/[REDACTED]';
// Fetch the token.
const tokenResponse = await fetch(tokenUrl, {
method: 'GET',
headers: {
'Content-Type': 'text/plain',
'Hashbangcode-Extension': 'link-it',
}
});
// Extract the token.
const tokenResult = await tokenResponse.text();
// Send link data with our key and csrf token.
const response = await fetch(linkCreateUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Hashbangcode-Extension': 'link-it',
'X-CSRF-Token': tokenResult,
'api-key': apiKey,
},
body: JSON.stringify(data)
});
// Get the response.
const result = await response.json();
Once the screen has been resized we then wait for 100ms to ensure that the site has settled down before taking a screenshot of the site. We don’t need to wait longer than 100ms since we have already loaded the page, this is just the act of resizing the currently selected tab.This could be an article all on it’s own, so I’ll keep it somewhat brief. In basic terms, a Chrome extension is essentially a small website, created using normal HTML, JavaScript and CSS files. If you are familiar with writing JavaScript then Chrome extensions will be second nature really.chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === "take_screenshot") {
// We must call the async function and handle the response
captureExactSize(message.tabId, message.width, message.height)
.then((dataUrl) => sendResponse({ success: true, dataUrl }))
.catch((error) => sendResponse({ success: false, error: error.message }));
return true; // Keeps the message channel open for the async response.
}
});
The mechanism I came up with was to add a “Hashbangcode-Extension” header to the incoming request from the extension, which is then used to check if the incoming request is from the extension. Since the extension will only ask for two endpoints on the site we can also check that one of these two paths is being requested before allowing the request in.The mechanism to make a request with this system is slightly different to a GET resource. In order to authenticate with the site we first need to make a request to the endpoint /session/token, sending the key that we want to authenticate with. This will return a session token, which we then need to append to subsequent requests in order for them to be authenticated correctly. So whilst the GET request is a single request, a POST request requires two requests, one to authenticate, and another to make the request.Let’s have a look at how the extension can be used.In the last article in this series I looked at creating a link directory on a Drupal site. In that article I looked at how I set up the links and took screenshots of the sites using a headless Chromium browser as the links were added.
Creating A Chrome Extension
First, let’s look at creating the REST resource in Drupal.As for the link directory itself, there will be a part 3 in the not too distant future where I attempt to test all of the links on the site to detect broken links. This is an essential part of maintaining a link directory as it keeps the directory up to date without stumbling across broken links.What I’m going to do here is take a picture of the main php.net site using the Linkt It! extension and send this to the link directory.I therefore looked for a different mechanism. Since I wanted to take a screenshot of a website it made sense to me to use a browser to do this, and because I am already using a browser why not get the browser I’m using to take the screenshot. After a bit of research I realised that creating browser extensions to do this was actually pretty simple. Plus once the screenshot has been taken I can post this to the Drupal site using a REST resource.
- permissions : This is a list of the permissions associated with the extension. In this case I need:
- debugger : The screenshot mechanism is run using the Chrome debugger, so we need to allow permission to this system.
- tabs, activeTab : As we want to take a screenshot of the current, active, tab we need to give permission to access this information.
- storage : I want to store the access key in the extension, so it needs access to the storage permission to do this.
- action : This is used to create the extension icon in Chrome, and tells the browser what file to load initially.
- background : The screenshot is taken using a service_worker, and this option tells Chrome what script file to load as part of this service.
- host_permissions : In order to make a request to an endpoint we need to tell Chrome what endpoints we are allowing. Here I’m allowing the main site, and the ddev instance for debugging and development purposes.
- options_page : I’m not showing this in the examples below, but the user key for authentication is saved using an options page, which is just another HTML page that contains a small script.
With all of that in place we now have a extension we can use. Rather than upload what is essentially a private extension to the Google Web Store I have instead opted to install it locally. You can do this by loading the extensions page and selecting the options “Load unpacked” from the top of the page. This will open a dialog window that you just point to the location of the extension on your disk.The extension has a manifest.json file that gives some instructions to the browser about what the extension is called, what actions to perform, and what permissions the extension has. This is the manifest.json file for the Link It! extension.This has been a fun project to work through, with a number of interesting challenges to overcome. Creating the extension to take screenshots of sites took a surprising short amount of time to do, even though attempting to fix the issues did take a little while.document.getElementById('snap').addEventListener('click', async () => {
const sendStatus = document.getElementById('send-status');
sendStatus.classList.add('hidden');
sendStatus.innerHTML = '';
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
const status = document.getElementById('snap-status');
status.innerText = "Capturing...";
// Generate screenshot of the page using the service worker.
chrome.runtime.sendMessage({
action: "take_screenshot",
tabId: tab.id,
width: 1280,
height: 960
}, (response) => {
if (response?.success) {
status.innerHTML = "<br>";
document.getElementById('result-image').src = response.dataUrl;
document.getElementById('image-data').value = response.dataUrl;
} else {
status.innerText = "Error: " + (response?.error || "Unknown");
}
});
const titlefield = document.getElementById('title');
titlefield.classList.remove('hidden');
titlefield.value = tab.title;
const linkurl = document.getElementById('linkurl');
linkurl.classList.remove('hidden');
linkurl.value = tab.url;
const send = document.getElementById('send');
send.classList.remove('hidden');
});
The core of Drupal only accepts cookie authentication, which is sort of complicated for a REST resource as it needs you to authenticate using your username and password, collect the cookie from the page response, and then send that cookie every time you make a request (assuming that the cookie is still authenticates you).With the extension installed we see an icon in our extensions list. We can pin the icon the same way as normal Chrome extensions to make it always visible.The file index.html is just a plan HTML file, here is the contents of the body element of that file.If you want more information on what code is run here then the following pages from the Chrome docs might be of interest to you
– Page reference : https://chromedevtools.github.io/devtools-protocol/tot/Page/
– Emulation reference : https://chromedevtools.github.io/devtools-protocol/tot/Emulation/Now that we have the REST resource in place, and allowed the request to be accepted by the site, we can create a Chrome extension.This needs to accept the data from the Chrome extension and generate a Link content entity using that data.If we click the “Linkt It!” button it will trigger the requests to the site and either generate or update the link on the site. The result is a JSON response that contains a URL back to the link we just created, which we can click on to see the link in the directory (to add a description and tags). Clicking on “Grab Link Data” will resize the window and take a picture of the site in that new viewport size. The data is then presented to the user, along with options to be able to change the title and URL before submitting them to the link directory.Rather than post lost more code about how this is done, I’ve extracted the part of the code responsible for sending the request to the site. I called the extension “Link it!”, and created a directory to store the source files in.
<button id="snap">Grab Link Data</button>
<div id="snap-status"></div>
<img id="result-image" style="width: 100%; margin-top: 10px;" />
<input type="hidden" id="image-data" />
<input type="text" id="title" name="title" class="hidden" />
<input type="text" id="linkurl" name="linkurl" class="hidden" />
<button id="send" class="hidden">Link It!</button>
<p id="send-status" class="hidden">Success!</p>
<button id="go-to-options">options</button>
<script src="script.js"></script>
I’ve been using this extension for a few weeks now, and it’s been working very well. It was quite easy to put together all of this code, it just took a few evenings reading the Chrome internal documentation to resize windows and take screenshots. link_directory.decorate_seckit_event:
class: Drupallink_directoryEventSubscriberLinkDirectorySecKitEventSubscriber
decorates: seckit.subscriber
arguments: ['@logger.channel.seckit', '@config.factory', '@extension.list.module']
tags:
- { name: event_subscriber }
The issue I had was that when I used headless Chromium to take screenshots of the sites the success rate was not very high. In these days of AI attacks, site captcha checks, and cookie popups it turned out to be quite difficult to take a clean screenshot of a site without being blocked either by a CDN or a cookie popup. In fact, most of the time the screenshot would be just a CDN error page.


Conclusion
In this article we will look at setting up a rest resource to generate (or update) links, and then creating a Chrome extension to take a screenshot of a site at a set resolution.namespace Drupallink_directoryPluginrestresource;
use DrupalCoreStringTranslationTranslatableMarkup;
use DrupalfileFileInterface;
use Drupallink_directoryServiceLinkManagerServiceInterface;
use DrupalrestAttributeRestResource;
use DrupalrestModifiedResourceResponse;
use DrupalrestPluginResourceBase;
use SymfonyComponentDependencyInjectionContainerInterface;
use SymfonyComponentHttpFoundationRequest;
/**
* REST service for receiving link data.
*/
#[RestResource(
id: "link_directory_receive_link",
label: new TranslatableMarkup("Receive Link"),
uri_paths: [
"create" => "/api/links/[REDACTED]",
]
)]
class ReceiveLink extends ResourceBase {
protected LinkManagerServiceInterface $linkManagerService;
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
$instance = new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->getParameter('serializer.formats'),
$container->get('logger.factory')->get('rest')
);
$instance->linkManagerService = $container->get('link_directory.link_manager');
return $instance;
}
public function post(Request $request) {
$payload = $request->getContent();
try {
$data = json_decode($payload, TRUE, flags: JSON_THROW_ON_ERROR);
}
catch (Exception $e) {
return new ModifiedResourceResponse([], 422);
}
// Extract information from the data.
$link = $data['link'];
$title = $data['title'];
$fileData = $data['imagedata'];
// Create a Link node object.
$node = $this->linkManagerService->createLinkObject($link, $title);
// Turn off image update.
$node->set('field_link_image_update', TRUE);
// Remove head header from the base 64 string.
$fileData = substr($fileData, strpos($fileData, ',') + 1);
$file = $this->linkManagerService->createFileFromBase64Data($fileData, hash('sha1', $link));
if ($file instanceof FileInterface) {
$node->set('field_link_image', [
'target_id' => $file->id(),
'alt' => $title,
]);
}
// Save the node.
$node->save();
// Built response payload.
$return = [
'message' => 'Link created!',
'url' => $node->toUrl()->setAbsolute()->toString(),
];
// Do not cache the response.
return new ModifiedResourceResponse($return, 201);
}
}
With the Drupal REST plugin in place we can open the REST administration interface (powered by the REST UI module) to enable and configure it.
key_auth, which is how we will authenticate with the system. Each user can generate a key and use that to push data to the receive link endpoint, which authenticates them with the request. That means that when a link is created in directory we can tell who created the link.It’s much simpler to use a contributed module to do this, and I oped to use the Key Auth module. Using this module you can set each user on your site to have a key, and that key can be passed in with a request in a number of ways to authenticate that user onto the site. The keys can be easily rotated and have permissions associated with them so it’s easy to lock down the keys to certain users, or stop users from using keys.async function captureExactSize(tabId, width, height) {
const target = { tabId };
// 1. Attach the debugger
await chrome.debugger.attach(target, "1.3");
try {
// 2. Force the page to render at the specific dimensions
// deviceScaleFactor: 1 ensures a 1:1 pixel ratio (not retina/high-dpi)
await chrome.debugger.sendCommand(target, "Emulation.setDeviceMetricsOverride", {
width: width,
height: height,
deviceScaleFactor: 1,
mobile: false,
screenWidth: width, // Explicitly set the "monitor" size
screenHeight: height,
viewSize: { width: width, height: height }
});
// Set the page scaling factor.
await chrome.debugger.sendCommand(target, "Emulation.setPageScaleFactor", {
pageScaleFactor: 1
});
// 3. Give the browser a "tick" to reflow the layout
await new Promise(resolve => setTimeout(resolve, 100));
// 4. Capture the screenshot
const { data } = await chrome.debugger.sendCommand(target, "Page.captureScreenshot", {
format: "png",
clip: {
x: 0,
y: 0,
width: width,
height: height,
scale: 1,
},
fromSurface: true,
captureBeyondViewport: true,
});
// 5. Reset dimensions
await chrome.debugger.sendCommand(target, "Emulation.clearDeviceMetricsOverride");
return `data:image/png;base64,${data}`;
} finally {
// Always detach the debugger, even if an error occurs
await chrome.debugger.detach(target);
}
}
You might be wondering if the extension works in other browsers like Vivaldi, or Firefox? The answer is yes, quite surprisingly it works in just the same way. Even with non-Chrome based browsers it works very well.





