From 252f38bb0a3c3e83d7cd90b0e3254d9eb0e8e960 Mon Sep 17 00:00:00 2001 From: nala Date: Tue, 10 Nov 2026 10:41:50 +0200 Subject: [PATCH] feat(portal): expose bounded public sitemap --- .../Controllers/PublicSitemapController.php | 67 +++++++++++++++++++ app/Support/PublicSitemapXml.php | 23 +++++++ routes/web.php | 4 ++ tests/Unit/PublicSitemapTest.php | 65 ++++++++++++++++++ 4 files changed, 159 insertions(+) create mode 100644 app/Http/Controllers/PublicSitemapController.php create mode 100644 app/Support/PublicSitemapXml.php create mode 100644 tests/Unit/PublicSitemapTest.php diff --git a/app/Http/Controllers/PublicSitemapController.php b/app/Http/Controllers/PublicSitemapController.php new file mode 100644 index 00000000..d0fa7bc7 --- /dev/null +++ b/app/Http/Controllers/PublicSitemapController.php @@ -0,0 +1,67 @@ +base(); + $pages = (int) ceil(Opportunity::published()->count() / self::PAGE_SIZE); + $locations = (static function () use ($base, $pages): Generator { + yield $base.'/sitemap/pages.xml'; + for ($page = 1; $page <= $pages; $page++) { + yield $base.'/sitemap/opportunities/'.$page.'.xml'; + } + })(); + + return $this->response($xml, $locations, true); + } + + public function pages(PublicSitemapXml $xml): StreamedResponse + { + $base = $this->base(); + $paths = ['/', '/oportunidades', '/sectores', '/como-investir', '/documentos', '/apoio', '/simulador-fiscal', '/politica-de-privacidade', '/termos-de-uso']; + + return $this->response($xml, array_map(fn ($path) => $base.$path, $paths)); + } + + public function opportunities(string $page, PublicSitemapXml $xml): StreamedResponse + { + $number = filter_var($page, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 1000000]]); + abort_if($number === false, 404); + $base = $this->base(); + $records = Opportunity::published()->orderBy('id')->offset(($number - 1) * self::PAGE_SIZE)->limit(self::PAGE_SIZE)->get(['slug']); + abort_if($records->isEmpty(), 404); + + return $this->response($xml, $records->map(fn ($record) => $base.'/oportunidades/'.rawurlencode($record->slug))); + } + + private function base(): string + { + $url = rtrim((string) config('app.url'), '/'); + $parts = parse_url($url); + abort_unless(filter_var($url, FILTER_VALIDATE_URL) && is_array($parts) + && in_array($parts['scheme'] ?? null, ['http', 'https'], true) + && ! isset($parts['query']) && ! isset($parts['fragment']) && ! isset($parts['user']) && ! isset($parts['pass']), 503); + + return $url; + } + + /** @param iterable $locations */ + private function response(PublicSitemapXml $xml, iterable $locations, bool $index = false): StreamedResponse + { + return response()->stream(static function () use ($xml, $locations, $index): void { + foreach ($xml->chunks($locations, $index) as $chunk) { + echo $chunk; + } + }, 200, ['Content-Type' => 'application/xml; charset=UTF-8', 'Cache-Control' => 'no-store', 'X-Content-Type-Options' => 'nosniff']); + } +} diff --git a/app/Support/PublicSitemapXml.php b/app/Support/PublicSitemapXml.php new file mode 100644 index 00000000..cddc0a18 --- /dev/null +++ b/app/Support/PublicSitemapXml.php @@ -0,0 +1,23 @@ + $locations + * @return Generator + */ + public function chunks(iterable $locations, bool $index = false): Generator + { + $root = $index ? 'sitemapindex' : 'urlset'; + $item = $index ? 'sitemap' : 'url'; + yield ''."\n"; + yield '<'.$root.' xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'."\n"; + foreach ($locations as $location) { + yield '<'.$item.'>'.htmlspecialchars($location, ENT_XML1 | ENT_QUOTES, 'UTF-8').''."\n"; + } + yield ''; + } +} diff --git a/routes/web.php b/routes/web.php index a1622313..0239e7ac 100644 --- a/routes/web.php +++ b/routes/web.php @@ -47,6 +47,7 @@ use App\Http\Controllers\ProductUsageMetricsController; use App\Http\Controllers\PublicBusinessOpportunityController; use App\Http\Controllers\PublicGeographyController; use App\Http\Controllers\PublicPortalController; +use App\Http\Controllers\PublicSitemapController; use App\Http\Controllers\SectorReconciliationController; use App\Http\Controllers\TerritoryGeometryImportController; use App\Http\Middleware\AiEnabled; @@ -67,6 +68,9 @@ use Laravel\Fortify\Features; // Route::inertia('/', 'welcome')->name('home'); Route::get('/', [PortalServicesController::class, 'home'])->name('home'); +Route::get('/sitemap.xml', [PublicSitemapController::class, 'index']); +Route::get('/sitemap/pages.xml', [PublicSitemapController::class, 'pages']); +Route::get('/sitemap/opportunities/{page}.xml', [PublicSitemapController::class, 'opportunities'])->whereNumber('page'); Route::inertia('/contextos-de-investimento', 'portal/investment-contexts')->name('portal.investment-contexts'); Route::get('/formularios/fiscais', function (Request $request) { $input = $request->validate(['imposto' => ['nullable', 'in:iva,irpc']]); diff --git a/tests/Unit/PublicSitemapTest.php b/tests/Unit/PublicSitemapTest.php new file mode 100644 index 00000000..5ba4905a --- /dev/null +++ b/tests/Unit/PublicSitemapTest.php @@ -0,0 +1,65 @@ +never(); + } + + public function test_streamed_xml_escapes_locations_and_has_distinct_url_and_index_elements(): void + { + $xml = new PublicSitemapXml; + $locations = ['https://example.invalid/oportunidades/qa?a=1&b=2']; + $document = implode('', iterator_to_array($xml->chunks($locations))); + $parsed = new \DOMDocument; + $this->assertTrue($parsed->loadXML($document)); + $this->assertStringContainsString('&b=2', $document); + $this->assertSame($locations[0], $parsed->getElementsByTagName('loc')->item(0)->textContent); + $index = implode('', iterator_to_array($xml->chunks($locations, true))); + $this->assertStringContainsString('assertStringContainsString('', $index); + } + + public function test_static_sitemap_uses_the_configured_origin_and_contains_no_private_workspace(): void + { + config(['app.url' => 'https://example.invalid']); + $response = app(PublicSitemapController::class)->pages(new PublicSitemapXml); + ob_start(); + $response->sendContent(); + $xml = ob_get_clean(); + $this->assertStringContainsString('https://example.invalid/simulador-fiscal', $xml); + $this->assertStringContainsString('https://example.invalid/apoio', $xml); + $this->assertStringNotContainsString('balcao-virtual', $xml); + $this->assertStringNotContainsString('/management', $xml); + $this->assertTrue($response->headers->hasCacheControlDirective('no-store')); + } + + public function test_invalid_page_is_refused_before_accessing_the_catalogue(): void + { + $this->expectException(HttpException::class); + app(PublicSitemapController::class)->opportunities('0', new PublicSitemapXml); + } + + public function test_origin_rejects_queries_fragments_credentials_and_non_http_schemes(): void + { + foreach (['https://example.invalid?q=1', 'https://example.invalid#fragment', 'https://user:pass@example.invalid', 'javascript:alert(1)'] as $base) { + config(['app.url' => $base]); + try { + app(PublicSitemapController::class)->pages(new PublicSitemapXml); + $this->fail('Invalid sitemap origin was accepted.'); + } catch (HttpException $error) { + $this->assertSame(503, $error->getStatusCode()); + } + } + } +}