diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e40622..e5f6bfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Classes/Middleware/ApiKeyVerificationMiddleware.php b/Classes/Middleware/ApiKeyVerificationMiddleware.php new file mode 100644 index 0000000..367ec4c --- /dev/null +++ b/Classes/Middleware/ApiKeyVerificationMiddleware.php @@ -0,0 +1,68 @@ +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', + ], + ); + } +} diff --git a/Configuration/RequestMiddlewares.php b/Configuration/RequestMiddlewares.php new file mode 100644 index 0000000..736d286 --- /dev/null +++ b/Configuration/RequestMiddlewares.php @@ -0,0 +1,23 @@ + [ + 'jweiland/indexnow/api-key-verification' => [ + 'target' => ApiKeyVerificationMiddleware::class, + 'after' => [ + 'typo3/cms-frontend/site', + ], + ], + ], +]; diff --git a/Configuration/Sets/IndexNow/config.yaml b/Configuration/Sets/IndexNow/config.yaml new file mode 100644 index 0000000..482063e --- /dev/null +++ b/Configuration/Sets/IndexNow/config.yaml @@ -0,0 +1,2 @@ +name: jweiland/indexnow-default +label: 'IndexNow - Default Set' diff --git a/Configuration/Sets/IndexNow/settings.definitions.yaml b/Configuration/Sets/IndexNow/settings.definitions.yaml new file mode 100644 index 0000000..e64f588 --- /dev/null +++ b/Configuration/Sets/IndexNow/settings.definitions.yaml @@ -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: '' diff --git a/README.md b/README.md index f781d30..872cbed 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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` @@ -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 diff --git a/Resources/Private/Language/locallang_db.xlf b/Resources/Private/Language/locallang_db.xlf new file mode 100644 index 0000000..1030851 --- /dev/null +++ b/Resources/Private/Language/locallang_db.xlf @@ -0,0 +1,13 @@ + + + + + + IndexNow API Key + + + Per-site API key for the IndexNow search engine verification endpoint. Obtain a key at https://www.indexnow.org/. + + + + diff --git a/Tests/Unit/Middleware/ApiKeyVerificationMiddlewareTest.php b/Tests/Unit/Middleware/ApiKeyVerificationMiddlewareTest.php new file mode 100644 index 0000000..05213c0 --- /dev/null +++ b/Tests/Unit/Middleware/ApiKeyVerificationMiddlewareTest.php @@ -0,0 +1,154 @@ +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()); + } +} diff --git a/composer.json b/composer.json index 64a0aa5..f57def4 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,10 @@ { "name": "Stefan Froemken", "email": "sfroemken@jweiland.net" + }, + { + "name": "Hoja Mustaffa Abdul Latheef", + "email": "hlatheef@jweiland.net" } ], "require": { diff --git a/ext_conf_template.txt b/ext_conf_template.txt index 2fcecf5..28d71a0 100644 --- a/ext_conf_template.txt +++ b/ext_conf_template.txt @@ -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 diff --git a/ext_emconf.php b/ext_emconf.php index 9f02632..1c52eae 100644 --- a/ext_emconf.php +++ b/ext_emconf.php @@ -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', + 'state' => 'stable', + 'version' => '0.0.10', 'constraints' => [ 'depends' => [ 'typo3' => '12.4.31-13.4.99',