feat(portal): expose bounded public sitemap

This commit is contained in:
2026-11-10 10:41:50 +02:00
parent 7f13149723
commit 252f38bb0a
4 changed files with 159 additions and 0 deletions
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\Opportunity;
use App\Support\PublicSitemapXml;
use Generator;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PublicSitemapController extends Controller
{
private const PAGE_SIZE = 1000;
public function index(PublicSitemapXml $xml): StreamedResponse
{
$base = $this->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<string> $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']);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Support;
use Generator;
final class PublicSitemapXml
{
/** @param iterable<string> $locations
* @return Generator<int, string>
*/
public function chunks(iterable $locations, bool $index = false): Generator
{
$root = $index ? 'sitemapindex' : 'urlset';
$item = $index ? 'sitemap' : 'url';
yield '<?xml version="1.0" encoding="UTF-8"?>'."\n";
yield '<'.$root.' xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'."\n";
foreach ($locations as $location) {
yield '<'.$item.'><loc>'.htmlspecialchars($location, ENT_XML1 | ENT_QUOTES, 'UTF-8').'</loc></'.$item.'>'."\n";
}
yield '</'.$root.'>';
}
}
+4
View File
@@ -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']]);
+65
View File
@@ -0,0 +1,65 @@
<?php
namespace Tests\Unit;
use App\Http\Controllers\PublicSitemapController;
use App\Support\PublicSitemapXml;
use Illuminate\Foundation\Testing\TestCase;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\HttpException;
class PublicSitemapTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
DB::shouldReceive('connection')->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('&amp;b=2', $document);
$this->assertSame($locations[0], $parsed->getElementsByTagName('loc')->item(0)->textContent);
$index = implode('', iterator_to_array($xml->chunks($locations, true)));
$this->assertStringContainsString('<sitemapindex', $index);
$this->assertStringContainsString('<sitemap><loc>', $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());
}
}
}
}