Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9001ea3
[FEATURE] Serve IndexNow API key verification file automatically
Mar 26, 2026
e1b5660
Merge PR #30: [FEATURE] Serve IndexNow API key verification file auto…
hojalatheef May 28, 2026
4b9367f
[BUGFIX] Handle site-specific paths and improve API key handling
hojalatheef May 29, 2026
fb2427a
[BUGFIX] Simplify namespace usage in middleware configuration
hojalatheef May 29, 2026
be2ddff
[RELEASE] Mark extension as stable and update to version 1.0.0
hojalatheef May 29, 2026
5ac5761
[TEST] Add functional tests for ApiKeyVerificationMiddleware
hojalatheef May 29, 2026
beeb89a
[TASK] Add license header to `RequestMiddlewares` and fix file format…
hojalatheef May 29, 2026
5641ceb
Update Classes/Middleware/ApiKeyVerificationMiddleware.php
hojalatheef Jun 9, 2026
a5be975
Update Tests/Functional/Middleware/ApiKeyVerificationMiddlewareTest.php
hojalatheef Jun 9, 2026
ef06e89
[REFACTOR] Remove functional tests and replace API key handling using…
hojalatheef Jun 9, 2026
0164ec8
[REFACTOR] Enforce strict types and update middleware configuration
hojalatheef Jun 9, 2026
cfe8d2e
[TEST] Add unit tests for `ApiKeyVerificationMiddleware`
hojalatheef Jun 9, 2026
1a3a6a3
[FEATURE] Add default configuration and localization for IndexNow ext…
hojalatheef Jun 9, 2026
6c0f300
[TASK] Add new author and update extension version to 0.0.10
hojalatheef Jun 9, 2026
b9cc3a3
[BUGFIX] Add missing newline at EOF in ApiKeyVerificationMiddlewareTest
hojalatheef Jun 9, 2026
786b735
[TASK] Update CI workflow to include unit tests and expand functional…
hojalatheef Jun 9, 2026
61630e5
[REFACTOR] Remove `ApiKeyVerificationMiddlewareTest` and associated t…
hojalatheef Jun 9, 2026
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
11 changes: 7 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,14 @@ jobs:
- name: 'CGL'
run: Build/Scripts/runTests.sh -n -p ${{ matrix.php }} -s cgl

- name: 'Execute functional tests'
run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -d mysql -s functional
- name: 'Execute unit tests'
run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -s unit

- name: 'Execute functional tests'
- name: 'Execute functional tests with MariaDB'
run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -d mariadb -s functional

- name: 'Execute functional tests'
- name: 'Execute functional tests with SQLite'
run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -d sqlite -s functional

- name: 'Execute functional tests with Postgres'
run: Build/Scripts/runTests.sh -p ${{ matrix.php }} -d postgres -s functional
68 changes: 68 additions & 0 deletions Classes/Middleware/ApiKeyVerificationMiddleware.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

/*
* This file is part of the package jweiland/indexnow.
*
* For the full copyright and license information, please read the
* LICENSE file that was distributed with this source code.
*/

namespace JWeiland\IndexNow\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;

/**
* Serves the IndexNow API key verification file at /{apiKey}.txt
*
* IndexNow requires a verification file at the root of the website
* whose filename and content match the configured API key. This
* middleware dynamically serves that endpoint, using the API key
* stored in the extension configuration,
* removing the need to manually create or update a static file.
*
* @see https://www.indexnow.org/documentation
*/
final readonly class ApiKeyVerificationMiddleware implements MiddlewareInterface
{
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$site = $request->getAttribute('site');
if (!$site instanceof SiteInterface) {
return $handler->handle($request);
}

$apiKey = (string)$site->getSettings()->get('indexnow.apiKey', '');
if ($apiKey === '') {
return $handler->handle($request);
}

$serverParams = $request->getServerParams();
$requestPath = parse_url($serverParams['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?? '/';
$path = ltrim($requestPath, '/');
$sitePath = ltrim($site->getBase()->getPath(), '/');
if ($sitePath !== '' && str_starts_with($path, $sitePath)) {
$path = ltrim(substr($path, strlen($sitePath)), '/');
}

if ($path !== $apiKey . '.txt') {
return $handler->handle($request);
}

return new HtmlResponse(
$apiKey,
200,
[
'Content-Type' => 'text/plain; charset=utf-8',
'Content-Length' => (string)strlen($apiKey),
'Cache-Control' => 'public, max-age=86400',
],
);
}
}
23 changes: 23 additions & 0 deletions Configuration/RequestMiddlewares.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

/*
* This file is part of the package jweiland/indexnow.
*
* For the full copyright and license information, please read the
* LICENSE file that was distributed with this source code.
*/

use JWeiland\IndexNow\Middleware\ApiKeyVerificationMiddleware;

return [
'frontend' => [
'jweiland/indexnow/api-key-verification' => [
'target' => ApiKeyVerificationMiddleware::class,
'after' => [
'typo3/cms-frontend/site',
Comment thread
hojalatheef marked this conversation as resolved.
],
],
],
];
2 changes: 2 additions & 0 deletions Configuration/Sets/IndexNow/config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
name: jweiland/indexnow-default
label: 'IndexNow - Default Set'
10 changes: 10 additions & 0 deletions Configuration/Sets/IndexNow/settings.definitions.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
categories:
indexnow:
label: 'IndexNow'
settings:
indexnow.apiKey:
label: 'LLL:EXT:indexnow/Resources/Private/Language/locallang_db.xlf:settings.indexnow.apiKey.label'
description: 'LLL:EXT:indexnow/Resources/Private/Language/locallang_db.xlf:settings.indexnow.apiKey.description'
category: 'indexnow'
type: string
default: ''
38 changes: 20 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,18 +77,21 @@ engines.
Notify batch mode
: If enabled, the search engine will be notified using batch mode. This means that modified URLs are sent in a single request. A maximum of 10,000 URLs can be sent per batch

### Host API Key file
### API Key Verification File

Create a file named `[API key].txt` with your API key as content and move it
into your document root directory of your website server.
IndexNow search engines [verify ownership](https://www.indexnow.org/documentation)
by requesting a text file at `https://example.com/{apiKey}.txt` whose content
matches the API key.

#### Example
This extension **serves the verification file automatically** from the
configured API key — no manual file creation needed. Once you save the API key
in the extension settings, the file is immediately available at
`https://your-site.com/{apiKey}.txt`.

If you chose `abc-ABC-123` as your API key you have to create a file named
`abc-ABC-123.txt` and set `abc-ABC-123` as content of that file. Upload file
`abc-ABC-123.txt` into the `/var/www/my-typo3-page/public` folder. Open
`https://example.com/abc-ABC-123.txt` to make sure the file is public
available and its content is `abc-ABC-123`.
> **Note:** The verification file is served by a PSR-15 middleware that runs
> early in the request pipeline. It only responds to root-level `.txt`
> requests matching the configured key. All other requests pass through
> unchanged.

### Task

Expand Down Expand Up @@ -131,14 +134,13 @@ var/log/typo3_indexnow_[hash].log

### IndexNow was informed, but search results are not updated

The IndexNow provider will use your API key and request the file:
The IndexNow provider will verify your API key by requesting
`https://your-site.com/{apiKey}.txt`. This file is served automatically
by the extension. If verification still fails, check:

```text
[API key].txt
```

with API key as content from your server. If it does not exist, validation
fails and search engines will provide updated information much later.
1. The API key is set in extension settings (`Settings` → `Configure extensions` → `indexnow`)
2. The file is accessible: open `https://your-site.com/{apiKey}.txt` in a browser
3. No caching proxy or WAF is blocking the `.txt` file request

### I have changed content, but there is no record in `tx_indexnow_stack`

Expand Down Expand Up @@ -205,8 +207,8 @@ Creating a new page or content element should call the IndexNow services.
Currently, only modified pages and content elements will be processed. We
should use the afterAllDatabaseOperations DataHandler hook instead.

Nice to have: Add a section into EXT:reports, if file with API key is
available.
Nice to have: Add a section into EXT:reports to check IndexNow
configuration health.

## Support

Expand Down
13 changes: 13 additions & 0 deletions Resources/Private/Language/locallang_db.xlf
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file source-language="en" datatype="plaintext" original="messages">
<body>
<trans-unit id="settings.indexnow.apiKey.label">
<source>IndexNow API Key</source>
</trans-unit>
<trans-unit id="settings.indexnow.apiKey.description">
<source>Per-site API key for the IndexNow search engine verification endpoint. Obtain a key at https://www.indexnow.org/.</source>
</trans-unit>
</body>
</file>
</xliff>
154 changes: 154 additions & 0 deletions Tests/Unit/Middleware/ApiKeyVerificationMiddlewareTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php

/*
* This file is part of the package jweiland/indexnow.
*
* For the full copyright and license information, please read the
* LICENSE file that was distributed with this source code.
*/

namespace JWeiland\IndexNow\Tests\Unit\Middleware;

use JWeiland\IndexNow\Middleware\ApiKeyVerificationMiddleware;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\Entity\SiteSettings;
use TYPO3\TestingFramework\Core\Unit\UnitTestCase;

class ApiKeyVerificationMiddlewareTest extends UnitTestCase
{
private ApiKeyVerificationMiddleware $subject;

private RequestHandlerInterface&MockObject $handlerMock;

protected function setUp(): void
{
parent::setUp();

$this->handlerMock = $this->createMock(RequestHandlerInterface::class);
$this->subject = new ApiKeyVerificationMiddleware();
}

protected function tearDown(): void
{
unset(
$this->subject,
$this->handlerMock,
);

parent::tearDown();
}

private function makeSite(string $base, string $apiKey): Site
{
return new Site(
'test',
1,
['base' => $base],
SiteSettings::createFromSettingsTree(['indexnow' => ['apiKey' => $apiKey]]),
);
}

#[Test]
public function processPassesThroughWhenNoSiteAttributePresent(): void
{
$handlerResponse = $this->createMock(ResponseInterface::class);
$this->handlerMock->expects(self::once())->method('handle')->willReturn($handlerResponse);

$request = new ServerRequest('https://example.com/some-key.txt', 'GET', 'php://input', [], ['REQUEST_URI' => '/some-key.txt']);
$response = $this->subject->process($request, $this->handlerMock);

self::assertSame($handlerResponse, $response);
}

#[Test]
public function processPassesThroughWhenNoApiKeyConfigured(): void
{
$handlerResponse = $this->createMock(ResponseInterface::class);
$this->handlerMock->expects(self::once())->method('handle')->willReturn($handlerResponse);

$site = new Site('test', 1, ['base' => 'https://example.com/'], SiteSettings::createFromSettingsTree([]));
$request = (new ServerRequest('https://example.com/some-key.txt', 'GET', 'php://input', [], ['REQUEST_URI' => '/some-key.txt']))
->withAttribute('site', $site);
$response = $this->subject->process($request, $this->handlerMock);

self::assertSame($handlerResponse, $response);
}

#[Test]
public function processPassesThroughNonTxtRequest(): void
{
$handlerResponse = $this->createMock(ResponseInterface::class);
$this->handlerMock->expects(self::once())->method('handle')->willReturn($handlerResponse);

$site = $this->makeSite('https://example.com/', 'my-api-key');
$request = (new ServerRequest('https://example.com/some-page', 'GET', 'php://input', [], ['REQUEST_URI' => '/some-page']))
->withAttribute('site', $site);
$response = $this->subject->process($request, $this->handlerMock);

self::assertSame($handlerResponse, $response);
}

#[Test]
public function processPassesThroughSubdirectoryTxtRequest(): void
{
$handlerResponse = $this->createMock(ResponseInterface::class);
$this->handlerMock->expects(self::once())->method('handle')->willReturn($handlerResponse);

$site = $this->makeSite('https://example.com/', 'my-api-key');
$request = (new ServerRequest('https://example.com/subdir/my-api-key.txt', 'GET', 'php://input', [], ['REQUEST_URI' => '/subdir/my-api-key.txt']))
->withAttribute('site', $site);
$response = $this->subject->process($request, $this->handlerMock);

self::assertSame($handlerResponse, $response);
}

#[Test]
public function processPassesThroughForNonMatchingTxtFile(): void
{
$handlerResponse = $this->createMock(ResponseInterface::class);
$this->handlerMock->expects(self::once())->method('handle')->willReturn($handlerResponse);

$site = $this->makeSite('https://example.com/', 'my-api-key');
$request = (new ServerRequest('https://example.com/other-file.txt', 'GET', 'php://input', [], ['REQUEST_URI' => '/other-file.txt']))
->withAttribute('site', $site);
$response = $this->subject->process($request, $this->handlerMock);

self::assertSame($handlerResponse, $response);
}

#[Test]
public function processReturnsApiKeyFileForMatchingRequest(): void
{
$this->handlerMock->expects(self::never())->method('handle');

$site = $this->makeSite('https://example.com/', 'my-api-key');
$request = (new ServerRequest('https://example.com/my-api-key.txt', 'GET', 'php://input', [], ['REQUEST_URI' => '/my-api-key.txt']))
->withAttribute('site', $site);
$response = $this->subject->process($request, $this->handlerMock);

self::assertSame(200, $response->getStatusCode());
self::assertSame('text/plain; charset=utf-8', $response->getHeaderLine('Content-Type'));
self::assertSame('public, max-age=86400', $response->getHeaderLine('Cache-Control'));
self::assertSame('10', $response->getHeaderLine('Content-Length'));
self::assertSame('my-api-key', (string)$response->getBody());
}

#[Test]
public function processHandlesSiteWithSubdirectoryBase(): void
{
$this->handlerMock->expects(self::never())->method('handle');

$site = $this->makeSite('https://example.com/de/', 'my-api-key');
$request = (new ServerRequest('https://example.com/de/my-api-key.txt', 'GET', 'php://input', [], ['REQUEST_URI' => '/de/my-api-key.txt']))
->withAttribute('site', $site);
$response = $this->subject->process($request, $this->handlerMock);

self::assertSame(200, $response->getStatusCode());
self::assertSame('my-api-key', (string)$response->getBody());
}
}
4 changes: 4 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
{
"name": "Stefan Froemken",
"email": "sfroemken@jweiland.net"
},
{
"name": "Hoja Mustaffa Abdul Latheef",
"email": "hlatheef@jweiland.net"
}
],
"require": {
Expand Down
2 changes: 0 additions & 2 deletions ext_conf_template.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# cat=basic; type=string; label=LLL:EXT:indexnow/Resources/Private/Language/ExtConf.xlf:apiKey
apiKey =
# cat=basic; type=string; label=LLL:EXT:indexnow/Resources/Private/Language/ExtConf.xlf:searchEngineEndpoint
searchEngineEndpoint = https://www.bing.com/indexnow
# cat=basic; type=boolean; label=LLL:EXT:indexnow/Resources/Private/Language/ExtConf.xlf:notifyBatchMode
Expand Down
8 changes: 4 additions & 4 deletions ext_emconf.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,10 @@
'title' => 'Index now',
'description' => 'TYPO3 extension to inform various search engines over IndexNow endpoint about content updates',
'category' => 'service',
'author' => 'Stefan Froemken',
'author_email' => 'sfroemken@jweiland.net',
'state' => 'experimental',
'version' => '0.0.9',
'author' => 'Stefan Froemken, Hoja Mustaffa Abdul Latheef',
'author_email' => 'projects@jweiland.net',
Comment thread
hojalatheef marked this conversation as resolved.
'state' => 'stable',
'version' => '0.0.10',
'constraints' => [
'depends' => [
'typo3' => '12.4.31-13.4.99',
Expand Down
Loading