Surface destination edits as the core dynamic short-link feature.
Deploy Ladill Link / deploy (push) Successful in 1m40s
Deploy Ladill Link / deploy (push) Successful in 1m40s
Move destination editing to the top of the link page, clarify that the short URL never changes, prevent caches from pinning old redirects, and test that later destination updates take effect immediately.
This commit is contained in:
@@ -116,7 +116,11 @@ class LinkController extends Controller
|
||||
return back()->withInput()->withErrors(['destination_url' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Link updated.');
|
||||
$message = isset($validated['destination_url'])
|
||||
? 'Destination updated. Your short link is unchanged — new clicks go to the new URL.'
|
||||
: 'Link updated.';
|
||||
|
||||
return back()->with('success', $message);
|
||||
}
|
||||
|
||||
public function destroy(ShortLink $link): RedirectResponse
|
||||
|
||||
@@ -21,9 +21,16 @@ class LinkRedirectController extends Controller
|
||||
$link = $this->links->resolve($request, $slug);
|
||||
|
||||
if ($link !== null && $link->isDirectRedirect()) {
|
||||
// Always read the current destination from the DB so edits take effect
|
||||
// immediately. 302 + no-store so browsers/CDNs do not pin an old Location.
|
||||
$target = $link->resolveRedirectUrl($request, $path);
|
||||
|
||||
return redirect()->away($target, 302);
|
||||
return redirect()
|
||||
->away($target, 302)
|
||||
->withHeaders([
|
||||
'Cache-Control' => 'no-store, no-cache, must-revalidate, max-age=0',
|
||||
'Pragma' => 'no-cache',
|
||||
]);
|
||||
}
|
||||
|
||||
// Catalog / QR ecosystem slugs: proxy so destination stays live in the source app.
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<input type="url" name="destination_url" id="destination_url" value="{{ old('destination_url') }}" required
|
||||
placeholder="https://example.com/page"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
|
||||
<p class="mt-1.5 text-xs text-slate-500">You can change this later. Your short link stays the same — only where it points updates.</p>
|
||||
@error('destination_url')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,12 +6,21 @@
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="text-2xl font-semibold text-slate-900">{{ $link->label ?: $link->slug }}</h1>
|
||||
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">{{ $link->sourceAppLabel() }}</span>
|
||||
@if ($link->is_managed_here)
|
||||
<span class="rounded-full bg-emerald-50 px-2 py-0.5 text-xs font-medium text-emerald-700">Dynamic destination</span>
|
||||
@endif
|
||||
</div>
|
||||
<a href="{{ $link->publicUrl() }}" target="_blank" rel="noopener"
|
||||
class="mt-1 inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700">
|
||||
{{ $link->publicUrl() }}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /></svg>
|
||||
</a>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<a href="{{ $link->publicUrl() }}" target="_blank" rel="noopener"
|
||||
class="inline-flex items-center gap-1 text-emerald-600 hover:text-emerald-700">
|
||||
{{ $link->publicUrl() }}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /></svg>
|
||||
</a>
|
||||
<x-copy-button :text="$link->publicUrl()" label="Copy" class="text-xs font-medium text-slate-500 hover:text-emerald-700" />
|
||||
</div>
|
||||
<p class="mt-2 max-w-xl text-sm text-slate-500">
|
||||
Your short link never changes. Update the destination anytime — the next click goes to the new URL.
|
||||
</p>
|
||||
</div>
|
||||
@if ($link->is_managed_here)
|
||||
<form method="POST" action="{{ route('user.links.destroy', $link) }}" onsubmit="return confirm('Delete this link?')">
|
||||
@@ -28,7 +37,50 @@
|
||||
|
||||
@if (! $link->is_managed_here)
|
||||
<div class="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
|
||||
This {{ \App\Models\ShortLink::publicHost() }} slug was created in {{ $link->sourceAppLabel() }}. Edit it there — changes sync back here automatically.
|
||||
This {{ \App\Models\ShortLink::publicHost() }} slug was created in {{ $link->sourceAppLabel() }}. Edit the destination there — the short link stays the same and updates automatically.
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($link->is_managed_here)
|
||||
<form method="POST" action="{{ route('user.links.update', $link) }}" class="space-y-4 rounded-xl border border-emerald-200 bg-white p-6 shadow-sm">
|
||||
@csrf @method('PATCH')
|
||||
|
||||
<div>
|
||||
<h2 class="text-base font-semibold text-slate-900">Destination</h2>
|
||||
<p class="mt-0.5 text-sm text-slate-500">
|
||||
Change where <span class="font-medium text-emerald-700">{{ $link->publicUrl() }}</span> sends people.
|
||||
Printed cards, QR codes, and old shares keep working.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="destination_url" class="block text-sm font-medium text-slate-700">Destination URL</label>
|
||||
<input type="url" name="destination_url" id="destination_url" value="{{ old('destination_url', $link->destination_url) }}" required
|
||||
placeholder="https://example.com/new-page"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
|
||||
@error('destination_url')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
@error('link')<p class="mt-1 text-sm text-red-600">{{ $message }}</p>@enderror
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="label" class="block text-sm font-medium text-slate-700">Label <span class="font-normal text-slate-400">(optional)</span></label>
|
||||
<input type="text" name="label" id="label" value="{{ old('label', $link->label) }}"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $link->is_active))
|
||||
class="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500">
|
||||
Link is active
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn-primary">Update destination</button>
|
||||
</form>
|
||||
@else
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="text-base font-semibold text-slate-900">Destination</h2>
|
||||
<p class="mt-1 break-all text-sm text-slate-600">{{ $link->destination_url }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -99,39 +151,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if ($link->is_managed_here)
|
||||
<form method="POST" action="{{ route('user.links.update', $link) }}" class="space-y-4 rounded-xl border border-slate-200 bg-white p-6">
|
||||
@csrf @method('PATCH')
|
||||
|
||||
<h2 class="text-base font-semibold text-slate-900">Settings</h2>
|
||||
|
||||
<div>
|
||||
<label for="destination_url" class="block text-sm font-medium text-slate-700">Destination URL</label>
|
||||
<input type="url" name="destination_url" id="destination_url" value="{{ old('destination_url', $link->destination_url) }}" required
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="label" class="block text-sm font-medium text-slate-700">Label</label>
|
||||
<input type="text" name="label" id="label" value="{{ old('label', $link->label) }}"
|
||||
class="mt-1 block w-full rounded-lg border-slate-300 shadow-sm focus:border-emerald-500 focus:ring-emerald-500">
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="hidden" name="is_active" value="0">
|
||||
<input type="checkbox" name="is_active" value="1" @checked(old('is_active', $link->is_active))
|
||||
class="rounded border-slate-300 text-emerald-600 focus:ring-emerald-500">
|
||||
Link is active
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn-primary">Save changes</button>
|
||||
</form>
|
||||
@else
|
||||
<div class="rounded-xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="text-base font-semibold text-slate-900">Destination</h2>
|
||||
<p class="mt-1 text-sm text-slate-600">{{ $link->destination_url }}</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</x-user-layout>
|
||||
|
||||
@@ -92,6 +92,39 @@ class LinkRedirectTest extends TestCase
|
||||
$this->assertSame('go.example.test', ShortLink::publicHost());
|
||||
}
|
||||
|
||||
public function test_changing_destination_later_redirects_to_new_url(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
$link = ShortLink::create([
|
||||
'user_id' => $user->id,
|
||||
'slug' => 'campaign',
|
||||
'source_app' => 'link',
|
||||
'source_kind' => 'redirect',
|
||||
'is_managed_here' => true,
|
||||
'destination_url' => 'https://example.com/old-landing',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$this->get('https://ladl.link/campaign')
|
||||
->assertRedirect('https://example.com/old-landing');
|
||||
|
||||
// Destination edit is a first-class Link feature (not frozen at create time).
|
||||
app(\App\Services\Link\LinkManagerService::class)->update($link, [
|
||||
'destination_url' => 'https://example.com/new-landing',
|
||||
'label' => 'Spring campaign',
|
||||
]);
|
||||
|
||||
$link->refresh();
|
||||
$this->assertSame('https://example.com/new-landing', $link->destination_url);
|
||||
// Short slug stays put — only the destination changes.
|
||||
$this->assertSame('campaign', $link->slug);
|
||||
$this->assertSame('https://ladl.link/campaign', $link->publicUrl());
|
||||
|
||||
$response = $this->get('https://ladl.link/campaign');
|
||||
$response->assertRedirect('https://example.com/new-landing');
|
||||
$this->assertStringContainsString('no-store', (string) $response->headers->get('Cache-Control'));
|
||||
}
|
||||
|
||||
public function test_catalog_qr_links_proxy_to_platform_instead_of_bad_destination(): void
|
||||
{
|
||||
$user = $this->user();
|
||||
|
||||
Reference in New Issue
Block a user