From ebdcd141bbb0216a86a720d4947b58d590dbcfcb Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 16 May 2026 00:52:16 +0200 Subject: [PATCH] feat(redirect): hot-path redirect controller with Redis cache, 302/404/410 Co-Authored-By: Claude Sonnet 4.6 --- app/Domains/Link/Models/Link.php | 8 ++- app/Domains/Workspace/Models/Workspace.php | 8 ++- app/Http/Controllers/RedirectController.php | 55 +++++++++++++++++++++ database/factories/LinkFactory.php | 23 +++++++++ database/factories/WorkspaceFactory.php | 22 +++++++++ routes/web.php | 6 +++ tests/Feature/Redirect/RedirectTest.php | 34 +++++++++++++ 7 files changed, 154 insertions(+), 2 deletions(-) create mode 100644 app/Http/Controllers/RedirectController.php create mode 100644 database/factories/LinkFactory.php create mode 100644 database/factories/WorkspaceFactory.php create mode 100644 tests/Feature/Redirect/RedirectTest.php diff --git a/app/Domains/Link/Models/Link.php b/app/Domains/Link/Models/Link.php index 09105ab..e7509d2 100644 --- a/app/Domains/Link/Models/Link.php +++ b/app/Domains/Link/Models/Link.php @@ -2,6 +2,7 @@ namespace App\Domains\Link\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -9,10 +10,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class Link extends Model { - use SoftDeletes; + use HasFactory, SoftDeletes; protected $guarded = ['id']; + protected static function newFactory(): \Database\Factories\LinkFactory + { + return \Database\Factories\LinkFactory::new(); + } + protected static function booted(): void { static::creating(function (self $model) { diff --git a/app/Domains/Workspace/Models/Workspace.php b/app/Domains/Workspace/Models/Workspace.php index fb456a9..06f3776 100644 --- a/app/Domains/Workspace/Models/Workspace.php +++ b/app/Domains/Workspace/Models/Workspace.php @@ -2,6 +2,7 @@ namespace App\Domains\Workspace\Models; +use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -9,10 +10,15 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class Workspace extends Model { - use SoftDeletes; + use HasFactory, SoftDeletes; protected $guarded = ['id']; + protected static function newFactory(): \Database\Factories\WorkspaceFactory + { + return \Database\Factories\WorkspaceFactory::new(); + } + protected static function booted(): void { static::creating(function (self $model) { diff --git a/app/Http/Controllers/RedirectController.php b/app/Http/Controllers/RedirectController.php new file mode 100644 index 0000000..b05880a --- /dev/null +++ b/app/Http/Controllers/RedirectController.php @@ -0,0 +1,55 @@ +cache->get($slug); + + if ($data === null) { + // Cache miss — try DB + $link = Link::where('slug', $slug) + ->whereNull('domain_id') + ->first(); + + if (! $link) { + abort(404); + } + + $this->cache->warmFromLink($link); + $data = $this->cache->get($slug); + } + + // 2. Status check + if (($data['status'] ?? '') !== 'active') { + abort(410); + } + + // 3. Expiry check + $expiresAt = $data['expires_at'] ?? ''; + if ($expiresAt !== '' && (int) $expiresAt < time()) { + abort(410); + } + + // 4. RecordClickJob dispatched in Task 7 + // \App\Domains\Analytics\Jobs\RecordClickJob::dispatch( + // (int) $data['link_id'], + // $request->ip(), + // $request->userAgent(), + // $request->headers->all() + // )->onQueue('clicks'); + + // 5. 302 redirect + return redirect($data['target'], 302); + } +} diff --git a/database/factories/LinkFactory.php b/database/factories/LinkFactory.php new file mode 100644 index 0000000..fe1e54b --- /dev/null +++ b/database/factories/LinkFactory.php @@ -0,0 +1,23 @@ + Workspace::factory(), + 'slug' => Str::random(6), + 'target_url' => fake()->url(), + 'status' => 'active', + ]; + } +} diff --git a/database/factories/WorkspaceFactory.php b/database/factories/WorkspaceFactory.php new file mode 100644 index 0000000..1a47f90 --- /dev/null +++ b/database/factories/WorkspaceFactory.php @@ -0,0 +1,22 @@ + fake()->company(), + 'slug' => Str::random(12), + 'owner_id' => User::factory(), + ]; + } +} diff --git a/routes/web.php b/routes/web.php index 5d4bce0..70f0a4d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -16,4 +16,10 @@ Route::view('profile', 'profile') ->middleware(['auth']) ->name('profile'); +// Redirect service — handles slugs on main domain and custom domains +// Placed after all named routes so dashboard/profile etc. are not caught here +Route::get('/{slug}', \App\Http\Controllers\RedirectController::class) + ->where('slug', '[a-zA-Z0-9_-]+') + ->name('redirect'); + require __DIR__.'/auth.php'; diff --git a/tests/Feature/Redirect/RedirectTest.php b/tests/Feature/Redirect/RedirectTest.php new file mode 100644 index 0000000..ae5e373 --- /dev/null +++ b/tests/Feature/Redirect/RedirectTest.php @@ -0,0 +1,34 @@ +create([ + 'slug' => 'abc123', + 'target_url' => 'https://example.com', + 'status' => 'active', + 'domain_id' => null, + ]); + + $response = $this->get('/abc123'); + + $response->assertRedirect('https://example.com'); + $response->assertStatus(302); +}); + +it('returns 404 for unknown slug', function () { + $response = $this->get('/nonexistent-xyz99'); + $response->assertStatus(404); +}); + +it('returns 410 for disabled link', function () { + Link::factory()->create([ + 'slug' => 'disabled1', + 'target_url' => 'https://example.com', + 'status' => 'disabled', + 'domain_id' => null, + ]); + + $response = $this->get('/disabled1'); + $response->assertStatus(410); +});