Add authentication, a public landing page, and guest data preview

Hand-rolled login/register/logout on Laravel's built-in Auth facade
rather than Breeze/Jetstream, which scaffold Tailwind v3 config and
would fight our existing Tailwind v4 CSS-first setup. Login/Register
are Livewire components; logout is a plain POST route (auth-gated,
CSRF-protected).

Routing: "/" is now a public landing page describing the project;
the sites list moved from "/" to "/sites" to make room for it.

Data gating: App\Support\GuestPreview::paginate() forces page 1 and a
5-row page size for unauthenticated visitors on Sites, Discover, and
a site's Pages/Backlinks tables, while total() still reflects the
real match count — authenticated users get the existing full
pagination unchanged. A shared guest-preview-notice component shows
"N of M results, sign in to see all" and only renders when something
is actually being hidden.

Testing: pointed the test suite at a dedicated MySQL database
(huginn_testing) instead of the default in-memory SQLite, since the
existing fulltext index migration isn't SQLite-compatible — this app
is MySQL-specific by now regardless. Added AuthFlowTest (register,
login, wrong-password rejection, logout) and GuestPreviewTest (guest
capped to a preview, authenticated sees everything), all passing.

Also fixed a real bug the tests caught: request()->session() isn't
reliably attached during Livewire's in-process test action calls;
switched Login/Register to the session() helper, which resolves the
session store directly and works in both contexts.
This commit is contained in:
m105
2026-07-25 15:22:42 +03:00
parent 4c0cc5a7f5
commit f5c9129774
18 changed files with 461 additions and 25 deletions
+7 -5
View File
@@ -3,18 +3,20 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Site; use App\Models\Site;
use App\Support\GuestPreview;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
class SiteController extends Controller class SiteController extends Controller
{ {
public function show(Site $site): View public function show(Site $site): View
{ {
$pages = $site->pages()->orderBy('url')->paginate(50, ['*'], 'pages_page'); $pages = GuestPreview::paginate($site->pages()->orderBy('url'), 50, 'pages_page');
$backlinks = $site->inboundLinks() $backlinks = GuestPreview::paginate(
->with(['site', 'sitePage']) $site->inboundLinks()->with(['site', 'sitePage'])->latest(),
->latest() 50,
->paginate(50, ['*'], 'backlinks_page'); 'backlinks_page'
);
$referringDomainsCount = $site->inboundLinks()->distinct()->count('site_id'); $referringDomainsCount = $site->inboundLinks()->distinct()->count('site_id');
$outboundLinksCount = $site->outboundLinks()->count(); $outboundLinksCount = $site->outboundLinks()->count();
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Auth;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('components.layouts.app')]
class Login extends Component
{
public string $email = '';
public string $password = '';
public bool $remember = false;
public function login(): void
{
$this->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (! Auth::attempt(['email' => $this->email, 'password' => $this->password], $this->remember)) {
throw ValidationException::withMessages([
'email' => 'These credentials do not match our records.',
]);
}
session()->regenerate();
$this->redirect(route('sites.index'), navigate: false);
}
public function render(): View
{
return view('livewire.auth.login');
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Livewire\Auth;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
use Livewire\Attributes\Layout;
use Livewire\Component;
#[Layout('components.layouts.app')]
class Register extends Component
{
public string $name = '';
public string $email = '';
public string $password = '';
public string $password_confirmation = '';
public function register(): void
{
$validated = $this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'confirmed', Password::defaults()],
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => $validated['password'],
]);
Auth::login($user);
session()->regenerate();
$this->redirect(route('sites.index'), navigate: false);
}
public function render(): View
{
return view('livewire.auth.register');
}
}
+3 -3
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Discover; namespace App\Livewire\Discover;
use App\Models\Site; use App\Models\Site;
use App\Support\GuestPreview;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
@@ -67,7 +68,7 @@ class Search extends Component
if (! $hasQuery) { if (! $hasQuery) {
$sites = new LengthAwarePaginator([], 0, 25, 1, ['path' => request()->url()]); $sites = new LengthAwarePaginator([], 0, 25, 1, ['path' => request()->url()]);
} else { } else {
$sites = Site::query() $sites = GuestPreview::paginate(Site::query()
->when( ->when(
$keyword !== '', $keyword !== '',
fn ($query) => $query->whereHas( fn ($query) => $query->whereHas(
@@ -98,8 +99,7 @@ class Search extends Component
} }
}]) }])
->orderByDesc('matching_pages_count') ->orderByDesc('matching_pages_count')
->orderBy('domain') ->orderBy('domain'));
->paginate(25);
} }
return view('livewire.discover.search', ['sites' => $sites, 'hasQuery' => $hasQuery]); return view('livewire.discover.search', ['sites' => $sites, 'hasQuery' => $hasQuery]);
+4 -2
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Sites; namespace App\Livewire\Sites;
use App\Models\Site; use App\Models\Site;
use App\Support\GuestPreview;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Url; use Livewire\Attributes\Url;
@@ -24,14 +25,15 @@ class Index extends Component
public function render(): View public function render(): View
{ {
$sites = Site::query() $sites = GuestPreview::paginate(
Site::query()
->withCount('pages') ->withCount('pages')
->when( ->when(
$this->search !== '', $this->search !== '',
fn ($query) => $query->where('domain', 'like', '%'.$this->search.'%') fn ($query) => $query->where('domain', 'like', '%'.$this->search.'%')
) )
->orderBy('domain') ->orderBy('domain')
->paginate(25); );
return view('livewire.sites.index', ['sites' => $sites]); return view('livewire.sites.index', ['sites' => $sites]);
} }
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Support;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\Relation;
/**
* Caps a listing to a small preview for unauthenticated visitors — always page 1,
* always a short page, regardless of what page/perPage the request asks for — while
* authenticated users get the real, full pagination. total() still reflects the true
* match count either way, so guests see "N results, sign in to see all" rather than a
* silently truncated list.
*/
class GuestPreview
{
public const LIMIT = 5;
public static function paginate(Builder|Relation $query, int $fullPerPage = 25, string $pageName = 'page'): LengthAwarePaginator
{
if (self::isLimited()) {
return $query->paginate(self::LIMIT, ['*'], $pageName, 1);
}
return $query->paginate($fullPerPage, ['*'], $pageName);
}
public static function isLimited(): bool
{
return ! auth()->check();
}
}
+6 -2
View File
@@ -23,8 +23,12 @@
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/> <env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/> <env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/> <env name="DB_CONNECTION" value="mysql"/>
<env name="DB_DATABASE" value=":memory:"/> <env name="DB_HOST" value="192.168.0.155"/>
<env name="DB_PORT" value="3306"/>
<env name="DB_DATABASE" value="huginn_testing"/>
<env name="DB_USERNAME" value="admin"/>
<env name="DB_PASSWORD" value="admin"/>
<env name="DB_URL" value=""/> <env name="DB_URL" value=""/>
<env name="MAIL_MAILER" value="array"/> <env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/> <env name="QUEUE_CONNECTION" value="sync"/>
@@ -0,0 +1,11 @@
@props(['paginator'])
@if ($paginator->total() > $paginator->count())
<p class="mt-4 rounded-md bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:bg-blue-950 dark:text-blue-300">
Showing a preview ({{ $paginator->count() }} of {{ number_format($paginator->total()) }} results).
<a href="{{ route('login') }}" class="font-medium underline">Sign in</a>
or
<a href="{{ route('register') }}" class="font-medium underline">create a free account</a>
to see everything.
</p>
@endif
@@ -12,11 +12,23 @@
<body class="min-h-screen bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100"> <body class="min-h-screen bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100">
<header class="border-b border-neutral-200 dark:border-neutral-800"> <header class="border-b border-neutral-200 dark:border-neutral-800">
<div class="mx-auto flex max-w-5xl items-center gap-6 px-4 py-4"> <div class="mx-auto flex max-w-5xl items-center gap-6 px-4 py-4">
<a href="{{ route('sites.index') }}" class="text-lg font-semibold">Huginn</a> <a href="{{ route('home') }}" class="text-lg font-semibold">Huginn</a>
<nav class="flex gap-4 text-sm"> <nav class="flex flex-1 gap-4 text-sm">
<a href="{{ route('sites.index') }}" class="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100">Sites</a> <a href="{{ route('sites.index') }}" class="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100">Sites</a>
<a href="{{ route('discover') }}" class="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100">Discover</a> <a href="{{ route('discover') }}" class="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100">Discover</a>
</nav> </nav>
<nav class="flex items-center gap-4 text-sm">
@auth
<span class="text-neutral-500">{{ auth()->user()->name }}</span>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100">Log out</button>
</form>
@else
<a href="{{ route('login') }}" class="text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100">Log in</a>
<a href="{{ route('register') }}" class="rounded-md bg-neutral-900 px-3 py-1.5 font-medium text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200">Sign up</a>
@endauth
</nav>
</div> </div>
</header> </header>
+27
View File
@@ -0,0 +1,27 @@
<x-layouts.app>
<div class="mx-auto max-w-3xl px-4 py-20 text-center">
<h1 class="text-4xl font-bold tracking-tight">Find out who you're really competing with</h1>
<p class="mt-4 text-lg text-neutral-600 dark:text-neutral-400">
Huginn indexes the web using Common Crawl's public archive and helps you find competitors
by product, location, and even the platform they're built on — e.g. "who sells honey with a
<span class="whitespace-nowrap">.es</span> domain," or "which of my competitors run WordPress."
</p>
<div class="mt-8 flex justify-center gap-4">
@auth
<a href="{{ route('discover') }}" class="rounded-md bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200">Go to Discover</a>
<a href="{{ route('sites.index') }}" class="rounded-md border border-neutral-300 px-5 py-2.5 text-sm font-medium hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-900">Browse sites</a>
@else
<a href="{{ route('register') }}" class="rounded-md bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200">Sign up free</a>
<a href="{{ route('login') }}" class="rounded-md border border-neutral-300 px-5 py-2.5 text-sm font-medium hover:bg-neutral-50 dark:border-neutral-700 dark:hover:bg-neutral-900">Log in</a>
@endauth
</div>
@guest
<p class="mt-10 text-sm text-neutral-500">
Browsing without an account shows a limited preview of results —
<a href="{{ route('register') }}" class="text-blue-600 hover:underline dark:text-blue-400">create a free account</a> to see everything.
</p>
@endguest
</div>
</x-layouts.app>
@@ -0,0 +1,46 @@
<div class="mx-auto max-w-sm px-4 py-16">
<h1 class="text-2xl font-semibold">Log in</h1>
<form wire:submit="login" class="mt-6 space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Email</label>
<input
type="email"
id="email"
wire:model="email"
autofocus
autocomplete="username"
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
>
@error('email') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
</div>
<div>
<label for="password" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Password</label>
<input
type="password"
id="password"
wire:model="password"
autocomplete="current-password"
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
>
@error('password') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
</div>
<label class="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-400">
<input type="checkbox" wire:model="remember" class="rounded border-neutral-300 dark:border-neutral-700">
Remember me
</label>
<button
type="submit"
class="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
>
Log in
</button>
</form>
<p class="mt-6 text-sm text-neutral-500">
Don't have an account? <a href="{{ route('register') }}" class="text-blue-600 hover:underline dark:text-blue-400">Sign up</a>.
</p>
</div>
@@ -0,0 +1,64 @@
<div class="mx-auto max-w-sm px-4 py-16">
<h1 class="text-2xl font-semibold">Create an account</h1>
<form wire:submit="register" class="mt-6 space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Name</label>
<input
type="text"
id="name"
wire:model="name"
autofocus
autocomplete="name"
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
>
@error('name') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
</div>
<div>
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Email</label>
<input
type="email"
id="email"
wire:model="email"
autocomplete="username"
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
>
@error('email') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
</div>
<div>
<label for="password" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Password</label>
<input
type="password"
id="password"
wire:model="password"
autocomplete="new-password"
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
>
@error('password') <p class="mt-1 text-sm text-red-600 dark:text-red-400">{{ $message }}</p> @enderror
</div>
<div>
<label for="password_confirmation" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300">Confirm password</label>
<input
type="password"
id="password_confirmation"
wire:model="password_confirmation"
autocomplete="new-password"
class="mt-1 w-full rounded-md border border-neutral-300 px-3 py-2 text-sm focus:border-neutral-500 focus:outline-none dark:border-neutral-700 dark:bg-neutral-900"
>
</div>
<button
type="submit"
class="w-full rounded-md bg-neutral-900 px-4 py-2 text-sm font-medium text-white hover:bg-neutral-700 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-200"
>
Sign up
</button>
</form>
<p class="mt-6 text-sm text-neutral-500">
Already have an account? <a href="{{ route('login') }}" class="text-blue-600 hover:underline dark:text-blue-400">Log in</a>.
</p>
</div>
@@ -69,6 +69,12 @@
</div> </div>
<div class="mt-4"> <div class="mt-4">
@auth
{{ $sites->links() }} {{ $sites->links() }}
@else
@if ($hasQuery)
<x-guest-preview-notice :paginator="$sites" />
@endif
@endauth
</div> </div>
</div> </div>
@@ -41,6 +41,10 @@
</div> </div>
<div class="mt-4"> <div class="mt-4">
@auth
{{ $sites->links() }} {{ $sites->links() }}
@else
<x-guest-preview-notice :paginator="$sites" />
@endauth
</div> </div>
</div> </div>
+8
View File
@@ -62,7 +62,11 @@
</div> </div>
<div class="mt-4"> <div class="mt-4">
@auth
{{ $pages->links() }} {{ $pages->links() }}
@else
<x-guest-preview-notice :paginator="$pages" />
@endauth
</div> </div>
<h2 class="mt-10 text-lg font-medium"> <h2 class="mt-10 text-lg font-medium">
@@ -127,7 +131,11 @@
</div> </div>
<div class="mt-4"> <div class="mt-4">
@auth
{{ $backlinks->links() }} {{ $backlinks->links() }}
@else
<x-guest-preview-notice :paginator="$backlinks" />
@endauth
</div> </div>
</div> </div>
</x-layouts.app> </x-layouts.app>
+18 -1
View File
@@ -1,10 +1,27 @@
<?php <?php
use App\Http\Controllers\SiteController; use App\Http\Controllers\SiteController;
use App\Livewire\Auth\Login;
use App\Livewire\Auth\Register;
use App\Livewire\Discover\Search as DiscoverSearch; use App\Livewire\Discover\Search as DiscoverSearch;
use App\Livewire\Sites\Index as SitesIndex; use App\Livewire\Sites\Index as SitesIndex;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', SitesIndex::class)->name('sites.index'); Route::view('/', 'home')->name('home');
Route::get('/sites', SitesIndex::class)->name('sites.index');
Route::get('/sites/{site}', [SiteController::class, 'show'])->name('sites.show'); Route::get('/sites/{site}', [SiteController::class, 'show'])->name('sites.show');
Route::get('/discover', DiscoverSearch::class)->name('discover'); Route::get('/discover', DiscoverSearch::class)->name('discover');
Route::middleware('guest')->group(function () {
Route::get('/login', Login::class)->name('login');
Route::get('/register', Register::class)->name('register');
});
Route::post('/logout', function () {
Auth::logout();
request()->session()->invalidate();
request()->session()->regenerateToken();
return redirect()->route('home');
})->middleware('auth')->name('logout');
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature;
use App\Livewire\Auth\Login;
use App\Livewire\Auth\Register;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class AuthFlowTest extends TestCase
{
use RefreshDatabase;
public function test_a_user_can_register(): void
{
Livewire::test(Register::class)
->set('name', 'Test User')
->set('email', 'testuser@example.com')
->set('password', 'correct-horse-battery-staple')
->set('password_confirmation', 'correct-horse-battery-staple')
->call('register')
->assertRedirect(route('sites.index'));
$this->assertAuthenticated();
$this->assertDatabaseHas('users', ['email' => 'testuser@example.com']);
}
public function test_a_user_can_log_in(): void
{
$user = User::factory()->create(['password' => 'correct-horse-battery-staple']);
Livewire::test(Login::class)
->set('email', $user->email)
->set('password', 'correct-horse-battery-staple')
->call('login')
->assertRedirect(route('sites.index'));
$this->assertAuthenticatedAs($user);
}
public function test_login_fails_with_wrong_password(): void
{
$user = User::factory()->create(['password' => 'correct-horse-battery-staple']);
Livewire::test(Login::class)
->set('email', $user->email)
->set('password', 'wrong-password')
->call('login')
->assertHasErrors('email');
$this->assertGuest();
}
public function test_a_user_can_log_out(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->post(route('logout'))
->assertRedirect(route('home'));
$this->assertGuest();
}
}
+44
View File
@@ -0,0 +1,44 @@
<?php
namespace Tests\Feature;
use App\Models\Site;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class GuestPreviewTest extends TestCase
{
use RefreshDatabase;
private function seedSites(int $count): void
{
for ($i = 0; $i < $count; $i++) {
Site::create([
'collection' => 'CC-MAIN-TEST',
'domain' => "example{$i}.com",
'url' => "https://example{$i}.com/",
]);
}
}
public function test_guest_sees_only_a_preview_of_sites(): void
{
$this->seedSites(8);
$response = $this->get(route('sites.index'));
$response->assertOk();
$response->assertSeeText('Showing a preview (5 of 8 results)');
}
public function test_authenticated_user_sees_all_sites(): void
{
$this->seedSites(8);
$response = $this->actingAs(User::factory()->create())->get(route('sites.index'));
$response->assertOk();
$response->assertDontSeeText('Showing a preview');
}
}