diff --git a/app/Console/Commands/SubmitIndexNow.php b/app/Console/Commands/SubmitIndexNow.php new file mode 100644 index 0000000..214752e --- /dev/null +++ b/app/Console/Commands/SubmitIndexNow.php @@ -0,0 +1,59 @@ +warn('IndexNow is disabled. Set INDEXNOW_ENABLED=true to enable submissions.'); + return self::FAILURE; + } + + $urls = $this->argument('urls'); + if ($this->option('all') || $urls === []) { + $urls = $this->allPublicUrls(); + } + + try { + $response = $indexNow->submit($urls); + } catch (Throwable $e) { + $this->error($e->getMessage()); + return self::FAILURE; + } + + if ($response === null || ! $response->successful()) { + $this->error('IndexNow rejected the submission'.($response ? ' (HTTP '.$response->status().').' : '.')); + return self::FAILURE; + } + + $this->info('Submitted '.count($urls).' URL(s) to IndexNow (HTTP '.$response->status().').'); + return self::SUCCESS; + } + + /** @return array */ + private function allPublicUrls(): array + { + $routes = ['home', 'features', 'buy', 'release', 'support', 'privacy', 'terms', 'refund', 'cookies']; + $urls = collect($routes)->filter(fn (string $name) => Route::has($name))->map(fn (string $name) => route($name)); + + $pages = CmsPage::query() + ->where('is_published', true) + ->where('robots_index', true) + ->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now())) + ->pluck('slug') + ->map(fn (string $slug) => route('cms.show', ['slug' => $slug])); + + return $urls->merge($pages)->unique()->values()->all(); + } +} diff --git a/app/Http/Controllers/Admin/CmsPageController.php b/app/Http/Controllers/Admin/CmsPageController.php index e12651d..720a62c 100644 --- a/app/Http/Controllers/Admin/CmsPageController.php +++ b/app/Http/Controllers/Admin/CmsPageController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; use App\Models\CmsPage; +use App\Services\IndexNowService; use Illuminate\Http\Request; use Illuminate\Support\Str; use Illuminate\Validation\Rule; @@ -12,11 +13,24 @@ final class CmsPageController extends Controller { public function index() { return view('admin.cms.index', ['pages' => CmsPage::latest()->paginate(25)]); } public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); } - public function store(Request $request) { $page = CmsPage::create($this->validated($request)); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); } + public function store(Request $request, IndexNowService $indexNow) { $page = CmsPage::create($this->validated($request)); $this->submitIfIndexable($page, $indexNow); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); } public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); } - public function update(Request $request, CmsPage $page) { $page->update($this->validated($request, $page)); return back()->with('status', 'Page updated.'); } + public function update(Request $request, CmsPage $page, IndexNowService $indexNow) { $page->update($this->validated($request, $page)); $this->submitIfIndexable($page, $indexNow); return back()->with('status', 'Page updated.'); } public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); } + private function submitIfIndexable(CmsPage $page, IndexNowService $indexNow): void + { + if (! $page->is_published || ! $page->robots_index || ($page->published_at && $page->published_at->isFuture())) { + return; + } + + try { + $indexNow->submit([route('cms.show', ['slug' => $page->slug])]); + } catch (\Throwable $e) { + report($e); + } + } + private function validated(Request $request, ?CmsPage $page = null): array { $request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]); diff --git a/app/Http/Controllers/SeoController.php b/app/Http/Controllers/SeoController.php index 59603f3..21439fe 100644 --- a/app/Http/Controllers/SeoController.php +++ b/app/Http/Controllers/SeoController.php @@ -37,6 +37,18 @@ final class SeoController extends Controller ->header('Cache-Control', 'public, max-age=3600'); } + public function indexNowKey(string $key): Response + { + $configured = trim((string) config('indexnow.key')); + + abort_unless(config('indexnow.enabled') && $configured !== '' && hash_equals($configured, $key), 404); + + return response($configured, 200, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + 'Cache-Control' => 'public, max-age=86400', + ]); + } + public function robots(): Response { $indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true); diff --git a/app/Services/IndexNowService.php b/app/Services/IndexNowService.php new file mode 100644 index 0000000..7c46357 --- /dev/null +++ b/app/Services/IndexNowService.php @@ -0,0 +1,65 @@ + $urls */ + public function submit(array $urls): ?Response + { + if (! config('indexnow.enabled')) { + Log::info('IndexNow submission skipped: disabled.'); + return null; + } + + $key = trim((string) config('indexnow.key')); + if ($key === '') { + throw new InvalidArgumentException('INDEXNOW_KEY is not configured.'); + } + + $urls = collect($urls) + ->filter(fn ($url) => is_string($url) && filter_var($url, FILTER_VALIDATE_URL)) + ->unique() + ->values() + ->all(); + + if ($urls === []) { + throw new InvalidArgumentException('No valid URLs were supplied to IndexNow.'); + } + + $host = parse_url($urls[0], PHP_URL_HOST); + if (! is_string($host) || $host === '') { + throw new InvalidArgumentException('Could not determine the IndexNow host.'); + } + + foreach ($urls as $url) { + if (parse_url($url, PHP_URL_HOST) !== $host) { + throw new InvalidArgumentException('All IndexNow URLs must use the same host.'); + } + } + + $response = Http::asJson() + ->acceptJson() + ->timeout(10) + ->retry(2, 500) + ->post((string) config('indexnow.endpoint'), [ + 'host' => $host, + 'key' => $key, + 'keyLocation' => 'https://'.$host.'/'.$key.'.txt', + 'urlList' => $urls, + ]); + + if ($response->successful()) { + Log::info('IndexNow URLs submitted.', ['host' => $host, 'count' => count($urls), 'status' => $response->status()]); + } else { + Log::warning('IndexNow submission failed.', ['host' => $host, 'count' => count($urls), 'status' => $response->status(), 'body' => $response->body()]); + } + + return $response; + } +} diff --git a/config/indexnow.php b/config/indexnow.php new file mode 100644 index 0000000..8d0f153 --- /dev/null +++ b/config/indexnow.php @@ -0,0 +1,7 @@ + (bool) env('INDEXNOW_ENABLED', false), + 'key' => env('INDEXNOW_KEY'), + 'endpoint' => env('INDEXNOW_ENDPOINT', 'https://api.indexnow.org/indexnow'), +]; diff --git a/routes/web.php b/routes/web.php index a3e6d6f..a78dee0 100755 --- a/routes/web.php +++ b/routes/web.php @@ -18,6 +18,7 @@ use App\Http\Middleware\AdminOnly; use Illuminate\Support\Facades\Route; Route::get('/sitemap.xml', [SeoController::class, 'sitemap'])->name('sitemap'); +Route::get('/{key}.txt', [SeoController::class, 'indexNowKey'])->where('key', '[A-Fa-f0-9]{8,128}')->name('indexnow.key'); Route::get('/robots.txt', [SeoController::class, 'robots'])->name('robots'); Route::get('/', fn () => view('portal.welcome', ['latestRelease' => \App\Models\SonarDeskRelease::published()->where('channel', 'stable')->latest('published_at')->first()]))->name('home');