diff --git a/app/Http/Controllers/LinkController.php b/app/Http/Controllers/LinkController.php index e5095d866..1468da7e0 100644 --- a/app/Http/Controllers/LinkController.php +++ b/app/Http/Controllers/LinkController.php @@ -11,6 +11,8 @@ use App\Queries\LinkCommitsQuery; use App\Queries\TopLinksQuery; use Illuminate\Container\Attributes\CurrentUser; +use Illuminate\Http\RedirectResponse; +use Illuminate\Support\Str; use Illuminate\Pagination\CursorPaginator; use Inertia\Inertia; use Inertia\Response; @@ -20,10 +22,32 @@ */ final readonly class LinkController { + public function lookup(ListLinksRequest $request): RedirectResponse + { + $url = $request->link(); + + if ($url === null) { + return to_route('link.index'); + } + + $token = Str::random(24); + $lookups = $request->session()->get('link_lookups', []); + $lookups[$token] = $url; + $request->session()->put('link_lookups', array_slice($lookups, -10, null, true)); + + return to_route('link.index', ['lookup' => $token]); + } + public function index(ListLinksRequest $request, #[CurrentUser] User $user, LinkCommitsQuery $commits, TopLinksQuery $topLinks): Response { $url = $request->link(); + if ($url === null && $request->filled('lookup')) { + $token = $request->string('lookup')->value(); + $url = $request->session()->get('link_lookups.'.$token); + abort_unless(is_string($url), 404); + } + if ($url === null) { return Inertia::render('links/index', [ 'url' => null, diff --git a/app/Http/Requests/ListLinksRequest.php b/app/Http/Requests/ListLinksRequest.php index df2b2cfd6..7a9e3ad0c 100644 --- a/app/Http/Requests/ListLinksRequest.php +++ b/app/Http/Requests/ListLinksRequest.php @@ -21,6 +21,7 @@ public function rules(): array { return [ 'url' => ['nullable', 'string', 'max:'.Urls::MAX_LENGTH], + 'lookup' => ['sometimes', 'string', 'regex:/^[A-Za-z0-9]{24}$/'], ]; } diff --git a/resources/js/pages/links/index.tsx b/resources/js/pages/links/index.tsx index a5f5f17c5..617074619 100644 --- a/resources/js/pages/links/index.tsx +++ b/resources/js/pages/links/index.tsx @@ -42,13 +42,35 @@ function peopleCount(count: number) { return count === 1 ? '1 person' : `${count} people`; } +function linkVisit(url: string) { + const destination = LinkController.index({ + query: url === '' ? {} : { url }, + }).url; + + if (destination.length > 1_800) { + return { + href: LinkController.lookup().url, + method: 'post' as const, + data: { url }, + preserveState: false, + }; + } + + return { href: destination, method: 'get' as const, data: {} }; +} + export default function Index(props: Props) { const [draft, setDraft] = useState(props.url ?? ''); const submit = (event: FormEvent) => { event.preventDefault(); - const url = draft.trim(); - router.get(LinkController.index({ query: url === '' ? {} : { url } })); + const visit = linkVisit(draft.trim()); + + if (visit.method === 'post') { + router.post(visit.href, visit.data, { preserveState: false }); + } else { + router.get(visit.href); + } }; return ( @@ -109,10 +131,8 @@ function TopLinks({ links }: { links: TopLink[] }) { {links.map((link, index) => (
  • diff --git a/routes/web.php b/routes/web.php index adb6d2c4d..a988d264d 100644 --- a/routes/web.php +++ b/routes/web.php @@ -443,6 +443,7 @@ // Every commit about a link, a prototype only reachable by its URL... Route::get('links', [LinkController::class, 'index'])->name('link.index'); + Route::post('links/lookup', [LinkController::class, 'lookup'])->name('link.lookup'); // Commit... Route::get('scheduled-commits', [ScheduledCommitController::class, 'index'])->name('scheduled-commit.index'); diff --git a/tests/Feature/Controllers/LinkControllerTest.php b/tests/Feature/Controllers/LinkControllerTest.php index 7539d01ae..e6f7dfb98 100644 --- a/tests/Feature/Controllers/LinkControllerTest.php +++ b/tests/Feature/Controllers/LinkControllerTest.php @@ -92,3 +92,33 @@ ->where('stats', ['commits' => 0, 'people' => 0]) ->where('commits', null)); }); + +it('uses a short session-backed GET URL for oversized link lookups', function (): void { + $url = 'https://example.com/'.str_repeat('a', 1900); + $link = LinkPreview::factory()->fetched()->create(['url' => $url]); + $first = Commit::factory()->create(['link_preview_id' => $link->id]); + + $response = $this->actingAs(User::factory()->create()) + ->post(route('link.lookup'), ['url' => $url]) + ->assertRedirect(); + + $location = $response->headers->get('Location'); + expect($location)->toBeString()->toContain('lookup='); + + // The GET destination remains valid on refresh and for cursor pagination. + $this->get($location) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('links/index') + ->where('url', $url) + ->where('commits.data.0.id', $first->id)); + + $this->get($location)->assertOk()->assertInertia(fn ($page) => $page + ->where('url', $url)); +}); + +it('does not accept an unknown long-link lookup token', function (): void { + $this->actingAs(User::factory()->create()) + ->get(route('link.index', ['lookup' => str_repeat('X', 24)])) + ->assertNotFound(); +});