Expand Link app with analytics, settings, and custom domains.
Deploy Ladill Link / deploy (push) Successful in 37s
Deploy Ladill Link / deploy (push) Successful in 37s
Add Bitly-style branded domain support via Ladill Domains SSL API, account analytics dashboard, settings page with default domain picker, and fix SSO/dashboard issues from QR Plus template leftovers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\CustomDomain\LinkCustomDomainService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class SslCallbackController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, LinkCustomDomainService $service): JsonResponse
|
||||
{
|
||||
$secret = (string) config('customdomain.callback_secret');
|
||||
$body = $request->getContent();
|
||||
$signature = (string) $request->header('X-Ladill-Signature', '');
|
||||
|
||||
if ($secret === '' || ! hash_equals(hash_hmac('sha256', $body, $secret), $signature)) {
|
||||
return response()->json(['error' => 'Invalid signature.'], 401);
|
||||
}
|
||||
|
||||
$payload = json_decode($body, true);
|
||||
$data = (array) ($payload['data'] ?? []);
|
||||
$host = (string) ($data['host'] ?? '');
|
||||
if ($host === '') {
|
||||
return response()->json(['error' => 'Missing host.'], 422);
|
||||
}
|
||||
|
||||
$service->applyCallback(
|
||||
$host,
|
||||
(string) ($data['status'] ?? 'failed'),
|
||||
$data['expires_at'] ?? null,
|
||||
$data['last_error'] ?? null,
|
||||
);
|
||||
|
||||
return response()->json(['status' => 'ok']);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrTeamMember;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Client\Response as HttpResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -81,7 +80,7 @@ class SsoLoginController extends Controller
|
||||
|
||||
public function callback(Request $request): RedirectResponse|View
|
||||
{
|
||||
$intended = (string) $request->session()->get('sso.intended', route('qr.dashboard'));
|
||||
$intended = (string) $request->session()->get('sso.intended', route('link.dashboard'));
|
||||
|
||||
$popup = (bool) $request->session()->get('sso.popup');
|
||||
|
||||
@@ -118,8 +117,6 @@ class SsoLoginController extends Controller
|
||||
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
|
||||
}
|
||||
|
||||
QrTeamMember::linkPendingInvitesFor($user);
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Link;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Link\LinkAnalyticsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AnalyticsController extends Controller
|
||||
{
|
||||
public function __construct(private LinkAnalyticsService $analytics) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
return view('links.analytics.index', [
|
||||
'summary' => $this->analytics->summaryForAccount($account),
|
||||
'dailyClicks' => $this->analytics->dailyClicksForAccount($account, 30),
|
||||
'topLinks' => $this->analytics->topLinksForAccount($account),
|
||||
'recentClicks' => $this->analytics->recentClicksForAccount($account),
|
||||
'referrers' => $this->analytics->referrersForAccount($account),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Link;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LinkCustomDomain;
|
||||
use App\Services\CustomDomain\DomainsSslClient;
|
||||
use App\Services\CustomDomain\LinkCustomDomainService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class CustomDomainController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly LinkCustomDomainService $service,
|
||||
private readonly DomainsSslClient $domains,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
return view('links.domains.index', [
|
||||
'domains' => $account->linkCustomDomains()->latest()->get(),
|
||||
'enabled' => $this->service->enabled(),
|
||||
'serverIp' => (string) config('customdomain.server_ip'),
|
||||
'publicDomain' => (string) config('link.public_domain', 'ladl.link'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($this->service->enabled(), 404);
|
||||
|
||||
$data = $request->validate([
|
||||
'host' => ['required', 'string', 'max:255', 'regex:/^(?!-)([a-z0-9-]+\.)+[a-z]{2,}$/i'],
|
||||
'include_www' => ['nullable', 'boolean'],
|
||||
'make_default' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$host = strtolower(preg_replace('/^www\./', '', trim($data['host'])));
|
||||
|
||||
if (LinkCustomDomain::where('host', $host)->exists()) {
|
||||
return back()->withErrors(['host' => 'That domain is already connected.']);
|
||||
}
|
||||
|
||||
$domain = $this->service->attach(
|
||||
$request->user(),
|
||||
$host,
|
||||
(bool) ($data['include_www'] ?? true),
|
||||
(bool) ($data['make_default'] ?? false),
|
||||
);
|
||||
|
||||
$this->domains->registerConnected(
|
||||
$host,
|
||||
(string) $request->user()->public_id,
|
||||
'Ladill Link',
|
||||
route('link.domains.index'),
|
||||
);
|
||||
|
||||
return back()->with('success', "Domain added. Point an A record for {$host} to ".config('customdomain.server_ip').', then click Verify.');
|
||||
}
|
||||
|
||||
public function verify(Request $request, LinkCustomDomain $customDomain): RedirectResponse
|
||||
{
|
||||
abort_unless($customDomain->user_id === $request->user()->id, 403);
|
||||
|
||||
[$ok, $message] = $this->service->verifyAndProvision($customDomain);
|
||||
|
||||
return back()->with($ok ? 'success' : 'error', $message);
|
||||
}
|
||||
|
||||
public function makeDefault(Request $request, LinkCustomDomain $customDomain): RedirectResponse
|
||||
{
|
||||
abort_unless($customDomain->user_id === $request->user()->id, 403);
|
||||
abort_unless($customDomain->isLive(), 422);
|
||||
|
||||
$this->service->setDefault($customDomain);
|
||||
|
||||
return back()->with('success', $customDomain->host.' is now your default short-link domain.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, LinkCustomDomain $customDomain): RedirectResponse
|
||||
{
|
||||
abort_unless($customDomain->user_id === $request->user()->id, 403);
|
||||
|
||||
$host = $customDomain->host;
|
||||
$customDomain->delete();
|
||||
$this->domains->removeConnected($host);
|
||||
|
||||
return back()->with('success', 'Custom domain removed.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Link;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LinkCustomDomain;
|
||||
use App\Models\LinkWallet;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SettingsController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$account = ladill_account();
|
||||
|
||||
return view('links.settings.index', [
|
||||
'pricePerLink' => LinkWallet::pricePerLink(),
|
||||
'defaultDomain' => $account->defaultLinkDomain(),
|
||||
'publicDomain' => (string) config('link.public_domain', 'ladl.link'),
|
||||
'customDomains' => $account->linkCustomDomains()->latest()->get(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateDefaultDomain(Request $request): RedirectResponse
|
||||
{
|
||||
$account = ladill_account();
|
||||
$validated = $request->validate([
|
||||
'domain' => ['required', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
if ($validated['domain'] === 'platform') {
|
||||
LinkCustomDomain::where('user_id', $account->id)->update(['is_default' => false]);
|
||||
|
||||
return back()->with('success', 'Default short-link domain set to ladl.link.');
|
||||
}
|
||||
|
||||
$domain = LinkCustomDomain::query()
|
||||
->where('user_id', $account->id)
|
||||
->where('id', $validated['domain'])
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($domain->isLive(), 422);
|
||||
|
||||
LinkCustomDomain::where('user_id', $account->id)->update(['is_default' => false]);
|
||||
$domain->forceFill(['is_default' => true])->save();
|
||||
|
||||
return back()->with('success', 'Default short-link domain updated.');
|
||||
}
|
||||
}
|
||||
@@ -4,28 +4,28 @@ namespace App\Http\Controllers\Public;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Link\LinkManagerService;
|
||||
use App\Services\Link\LinkPlatformProxy;
|
||||
use App\Support\LadillLink;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class LinkRedirectController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private LinkManagerService $links,
|
||||
private LinkPlatformProxy $platform,
|
||||
) {}
|
||||
|
||||
public function resolve(Request $request, string $slug): RedirectResponse|View
|
||||
public function resolve(Request $request, string $slug, ?string $path = null): RedirectResponse|Response
|
||||
{
|
||||
$link = $this->links->resolve($request, $slug);
|
||||
|
||||
if ($link !== null) {
|
||||
return redirect()->away($link->destination_url, 302);
|
||||
if ($path === null || $path === '') {
|
||||
$link = $this->links->resolve($request, $slug);
|
||||
if ($link !== null) {
|
||||
return redirect()->away($link->destination_url, 302);
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy QR / storefront codes still resolve on ladill.com/q/*
|
||||
$legacy = rtrim((string) config('app.platform_url', 'https://ladill.com'), '/').'/q/'.$slug;
|
||||
$qs = $request->getQueryString();
|
||||
|
||||
return redirect()->away($legacy.($qs ? '?'.$qs : ''), 302);
|
||||
return $this->platform->forward($request, $slug, $path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Customer-branded short-link domain (e.g. go.brand.com) for Ladill Link.
|
||||
*/
|
||||
class LinkCustomDomain extends Model
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_ACTIVE = 'active';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'host', 'include_www', 'is_default', 'status', 'ssl_status',
|
||||
'dns_verified_at', 'ssl_issued_at', 'ssl_expires_at', 'last_error',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'include_www' => 'boolean',
|
||||
'is_default' => 'boolean',
|
||||
'dns_verified_at' => 'datetime',
|
||||
'ssl_issued_at' => 'datetime',
|
||||
'ssl_expires_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function isLive(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_ACTIVE && $this->ssl_status === self::STATUS_ACTIVE;
|
||||
}
|
||||
|
||||
public function baseUrl(): string
|
||||
{
|
||||
return 'https://'.$this->host;
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::saving(function (LinkCustomDomain $domain) {
|
||||
$domain->host = strtolower(trim((string) $domain->host, " \t\n\r\0\x0B./"));
|
||||
$domain->host = preg_replace('/^www\./', '', $domain->host) ?: $domain->host;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -38,9 +38,12 @@ class ShortLink extends Model
|
||||
return $this->hasMany(LinkClick::class);
|
||||
}
|
||||
|
||||
public function publicUrl(): string
|
||||
public function publicUrl(?User $owner = null): string
|
||||
{
|
||||
return self::publicBaseUrl().'/'.$this->slug;
|
||||
$owner ??= $this->user;
|
||||
$base = $owner ? $owner->publicLinkBaseUrl() : self::publicBaseUrl();
|
||||
|
||||
return rtrim($base, '/').'/'.$this->slug;
|
||||
}
|
||||
|
||||
public static function publicBaseUrl(): string
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
/**
|
||||
@@ -27,6 +28,17 @@ class User extends Authenticatable
|
||||
return ['email_verified_at' => 'datetime', 'password' => 'hashed'];
|
||||
}
|
||||
|
||||
public function canAccessAccount(int $accountId): bool
|
||||
{
|
||||
return $accountId === $this->id;
|
||||
}
|
||||
|
||||
/** @return Collection<int, User> */
|
||||
public function accessibleAccounts(): Collection
|
||||
{
|
||||
return collect([$this]);
|
||||
}
|
||||
|
||||
public function linkWallet(): HasOne
|
||||
{
|
||||
return $this->hasOne(LinkWallet::class);
|
||||
@@ -37,6 +49,25 @@ class User extends Authenticatable
|
||||
return $this->hasMany(ShortLink::class);
|
||||
}
|
||||
|
||||
public function linkCustomDomains(): HasMany
|
||||
{
|
||||
return $this->hasMany(LinkCustomDomain::class);
|
||||
}
|
||||
|
||||
public function defaultLinkDomain(): ?LinkCustomDomain
|
||||
{
|
||||
return $this->linkCustomDomains()
|
||||
->where('is_default', true)
|
||||
->where('status', LinkCustomDomain::STATUS_ACTIVE)
|
||||
->where('ssl_status', LinkCustomDomain::STATUS_ACTIVE)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function publicLinkBaseUrl(): string
|
||||
{
|
||||
return $this->defaultLinkDomain()?->baseUrl() ?? ShortLink::publicBaseUrl();
|
||||
}
|
||||
|
||||
public function getOrCreateLinkWallet(): LinkWallet
|
||||
{
|
||||
return $this->linkWallet()->firstOrCreate(
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\CustomDomain;
|
||||
|
||||
class DnsResolver
|
||||
{
|
||||
/** @return array<int,string> */
|
||||
public function aRecords(string $host): array
|
||||
{
|
||||
$records = @dns_get_record($host, DNS_A);
|
||||
if (! is_array($records)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(fn ($r) => $r['ip'] ?? null, $records)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\CustomDomain;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DomainsSslClient
|
||||
{
|
||||
public function configured(): bool
|
||||
{
|
||||
return (string) config('customdomain.ssl_api_key', '') !== '';
|
||||
}
|
||||
|
||||
public function requestCertificate(string $host, bool $includeWww, string $callbackUrl): bool
|
||||
{
|
||||
if (! $this->configured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$res = Http::withToken((string) config('customdomain.ssl_api_key'))
|
||||
->acceptJson()->timeout(20)
|
||||
->post(config('customdomain.ssl_api_url').'/ssl/certificates', [
|
||||
'host' => $host,
|
||||
'target' => 'app',
|
||||
'include_www' => $includeWww,
|
||||
'callback_url' => $callbackUrl,
|
||||
'metadata' => ['nginx_include' => config('customdomain.nginx_include')],
|
||||
]);
|
||||
|
||||
if ($res->failed()) {
|
||||
Log::warning('DomainsSslClient: request failed', ['host' => $host, 'status' => $res->status()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('DomainsSslClient: request error', ['host' => $host, 'error' => $e->getMessage()]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function registerConnected(string $host, string $ownerPublicId, ?string $label = null, ?string $linkUrl = null): void
|
||||
{
|
||||
if (! $this->configured() || $ownerPublicId === '') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Http::withToken((string) config('customdomain.ssl_api_key'))->acceptJson()->timeout(10)
|
||||
->post(config('customdomain.ssl_api_url').'/connected-domains', array_filter([
|
||||
'host' => $host,
|
||||
'owner_public_id' => $ownerPublicId,
|
||||
'label' => $label,
|
||||
'link_url' => $linkUrl,
|
||||
]));
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('DomainsSslClient: connected register skipped', ['host' => $host, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeConnected(string $host): void
|
||||
{
|
||||
if (! $this->configured()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Http::withToken((string) config('customdomain.ssl_api_key'))->acceptJson()->timeout(10)
|
||||
->delete(config('customdomain.ssl_api_url').'/connected-domains/'.urlencode($host));
|
||||
} catch (\Throwable $e) {
|
||||
Log::info('DomainsSslClient: connected remove skipped', ['host' => $host, 'error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\CustomDomain;
|
||||
|
||||
use App\Models\LinkCustomDomain;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LinkCustomDomainService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly DnsResolver $dns,
|
||||
private readonly DomainsSslClient $ssl,
|
||||
) {}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return (bool) config('customdomain.enabled', true) && $this->ssl->configured();
|
||||
}
|
||||
|
||||
public function attach(User $user, string $host, bool $includeWww = true, bool $makeDefault = false): LinkCustomDomain
|
||||
{
|
||||
return DB::transaction(function () use ($user, $host, $includeWww, $makeDefault) {
|
||||
if ($makeDefault) {
|
||||
LinkCustomDomain::where('user_id', $user->id)->update(['is_default' => false]);
|
||||
}
|
||||
|
||||
return LinkCustomDomain::create([
|
||||
'user_id' => $user->id,
|
||||
'host' => $host,
|
||||
'include_www' => $includeWww,
|
||||
'is_default' => $makeDefault || ! LinkCustomDomain::where('user_id', $user->id)->exists(),
|
||||
'status' => LinkCustomDomain::STATUS_PENDING,
|
||||
'ssl_status' => LinkCustomDomain::STATUS_PENDING,
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array{0:bool,1:string} */
|
||||
public function verifyAndProvision(LinkCustomDomain $domain): array
|
||||
{
|
||||
$serverIp = (string) config('customdomain.server_ip');
|
||||
$ips = $this->dns->aRecords($domain->host);
|
||||
|
||||
if (! in_array($serverIp, $ips, true)) {
|
||||
$domain->forceFill([
|
||||
'last_error' => 'DNS not pointing to '.$serverIp.' yet (found: '.(implode(', ', $ips) ?: 'none').').',
|
||||
])->save();
|
||||
|
||||
return [false, 'DNS not verified yet. Add an A record for '.$domain->host.' pointing to '.$serverIp.', then try again.'];
|
||||
}
|
||||
|
||||
$domain->forceFill(['dns_verified_at' => Carbon::now(), 'last_error' => null])->save();
|
||||
|
||||
$requested = $this->ssl->requestCertificate(
|
||||
$domain->host,
|
||||
$domain->include_www,
|
||||
route('api.ssl-callback'),
|
||||
);
|
||||
|
||||
if (! $requested) {
|
||||
$domain->forceFill(['last_error' => 'Could not reach the SSL service. Please try again shortly.'])->save();
|
||||
|
||||
return [false, 'Domain verified, but issuing the certificate failed. Please retry in a moment.'];
|
||||
}
|
||||
|
||||
return [true, 'Domain verified. Issuing your SSL certificate — this usually takes under a minute.'];
|
||||
}
|
||||
|
||||
public function applyCallback(string $host, string $status, ?string $expiresAt, ?string $error): void
|
||||
{
|
||||
$domain = LinkCustomDomain::where('host', strtolower(trim($host)))->first();
|
||||
if (! $domain) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($status === 'active') {
|
||||
$domain->forceFill([
|
||||
'ssl_status' => LinkCustomDomain::STATUS_ACTIVE,
|
||||
'status' => LinkCustomDomain::STATUS_ACTIVE,
|
||||
'ssl_issued_at' => Carbon::now(),
|
||||
'ssl_expires_at' => $expiresAt ? Carbon::parse($expiresAt) : null,
|
||||
'last_error' => null,
|
||||
])->save();
|
||||
} else {
|
||||
$domain->forceFill([
|
||||
'ssl_status' => LinkCustomDomain::STATUS_FAILED,
|
||||
'status' => LinkCustomDomain::STATUS_FAILED,
|
||||
'last_error' => $error ?: 'Certificate issuance failed.',
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
|
||||
public function setDefault(LinkCustomDomain $domain): void
|
||||
{
|
||||
DB::transaction(function () use ($domain) {
|
||||
LinkCustomDomain::where('user_id', $domain->user_id)->update(['is_default' => false]);
|
||||
$domain->forceFill(['is_default' => true])->save();
|
||||
});
|
||||
}
|
||||
|
||||
public function isAppHost(string $host): bool
|
||||
{
|
||||
$host = preg_replace('/^www\./', '', strtolower(trim($host)));
|
||||
$known = array_filter([
|
||||
(string) config('customdomain.app_host'),
|
||||
(string) config('customdomain.public_host'),
|
||||
'ladl.link',
|
||||
'www.ladl.link',
|
||||
]);
|
||||
|
||||
return in_array($host, $known, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Link;
|
||||
|
||||
use App\Models\LinkClick;
|
||||
use App\Models\ShortLink;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LinkAnalyticsService
|
||||
{
|
||||
/** @return array{total_clicks:int,unique_clicks:int,clicks_7d:int,clicks_30d:int,links_total:int} */
|
||||
public function summaryForAccount(User $account): array
|
||||
{
|
||||
$links = ShortLink::query()->where('user_id', $account->id);
|
||||
$clicks = LinkClick::query()->whereIn('short_link_id', (clone $links)->select('id'));
|
||||
|
||||
return [
|
||||
'total_clicks' => (int) (clone $links)->sum('clicks_total'),
|
||||
'unique_clicks' => (int) (clone $links)->sum('unique_clicks_total'),
|
||||
'clicks_7d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(7))->count(),
|
||||
'clicks_30d' => (int) (clone $clicks)->where('clicked_at', '>=', now()->subDays(30))->count(),
|
||||
'links_total' => (int) (clone $links)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return Collection<int, object{date:string,total:int}> */
|
||||
public function dailyClicksForAccount(User $account, int $days = 30): Collection
|
||||
{
|
||||
$rows = LinkClick::query()
|
||||
->selectRaw('DATE(clicked_at) as date, COUNT(*) as total')
|
||||
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
|
||||
->where('clicked_at', '>=', now()->subDays($days - 1)->startOfDay())
|
||||
->groupBy('date')
|
||||
->orderBy('date')
|
||||
->get()
|
||||
->keyBy('date');
|
||||
|
||||
return collect(range(0, $days - 1))->map(function (int $offset) use ($rows, $days) {
|
||||
$date = now()->subDays($days - 1 - $offset)->toDateString();
|
||||
|
||||
return (object) [
|
||||
'date' => $date,
|
||||
'total' => (int) ($rows[$date]->total ?? 0),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** @return Collection<int, array{label:string,total:int}> */
|
||||
public function topLinksForAccount(User $account, int $limit = 8): Collection
|
||||
{
|
||||
return ShortLink::query()
|
||||
->where('user_id', $account->id)
|
||||
->orderByDesc('clicks_total')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn (ShortLink $link) => [
|
||||
'label' => $link->label ?: $link->slug,
|
||||
'slug' => $link->slug,
|
||||
'total' => $link->clicks_total,
|
||||
'url' => route('user.links.show', $link),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return Collection<int, object> */
|
||||
public function recentClicksForAccount(User $account, int $limit = 15): Collection
|
||||
{
|
||||
return LinkClick::query()
|
||||
->with('shortLink:id,slug,label')
|
||||
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
|
||||
->latest('clicked_at')
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
|
||||
/** @return Collection<int, array{label:string,total:int}> */
|
||||
public function referrersForAccount(User $account, int $limit = 8): Collection
|
||||
{
|
||||
return LinkClick::query()
|
||||
->select('referer', DB::raw('COUNT(*) as total'))
|
||||
->whereIn('short_link_id', ShortLink::query()->where('user_id', $account->id)->select('id'))
|
||||
->whereNotNull('referer')
|
||||
->where('referer', '!=', '')
|
||||
->groupBy('referer')
|
||||
->orderByDesc('total')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->map(fn ($row) => [
|
||||
'label' => $this->refererLabel((string) $row->referer),
|
||||
'total' => (int) $row->total,
|
||||
]);
|
||||
}
|
||||
|
||||
private function refererLabel(string $referer): string
|
||||
{
|
||||
$host = parse_url($referer, PHP_URL_HOST);
|
||||
|
||||
return is_string($host) && $host !== '' ? $host : $referer;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Link;
|
||||
|
||||
use App\Support\LadillLink;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Proxies ladl.link ecosystem paths to the platform QR resolver (/q/* on ladill.com).
|
||||
* The browser stays on ladl.link; resolution happens server-side.
|
||||
*/
|
||||
class LinkPlatformProxy
|
||||
{
|
||||
public function forward(Request $request, string $slug, ?string $path = null): Response
|
||||
{
|
||||
$platform = rtrim((string) config('app.platform_url', 'https://ladill.com'), '/');
|
||||
$target = $platform.'/q/'.$slug;
|
||||
if ($path !== null && $path !== '') {
|
||||
$target .= '/'.ltrim($path, '/');
|
||||
}
|
||||
if ($qs = $request->getQueryString()) {
|
||||
$target .= '?'.$qs;
|
||||
}
|
||||
|
||||
$client = Http::withOptions(['allow_redirects' => false])->timeout(15);
|
||||
$method = strtoupper($request->method());
|
||||
|
||||
$pending = match ($method) {
|
||||
'POST' => $client->withHeaders($this->forwardHeaders($request))->asForm()->post($target, $request->post()),
|
||||
'PATCH' => $client->withHeaders($this->forwardHeaders($request))->patch($target, $request->all()),
|
||||
'DELETE' => $client->withHeaders($this->forwardHeaders($request))->delete($target),
|
||||
default => $client->withHeaders($this->forwardHeaders($request))->get($target),
|
||||
};
|
||||
|
||||
$upstream = $pending->toPsrResponse();
|
||||
$response = new Response(
|
||||
(string) $upstream->getBody(),
|
||||
$upstream->getStatusCode(),
|
||||
$this->sanitizeHeaders($upstream->getHeaders())
|
||||
);
|
||||
|
||||
if ($response->isRedirection()) {
|
||||
$location = $response->headers->get('Location');
|
||||
if (is_string($location)) {
|
||||
$response->headers->set('Location', $this->rewriteLocation($location, $slug));
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private function forwardHeaders(Request $request): array
|
||||
{
|
||||
$headers = [];
|
||||
foreach (['Accept', 'Accept-Language', 'User-Agent', 'Referer'] as $name) {
|
||||
$value = $request->header($name);
|
||||
if ($value !== null) {
|
||||
$headers[$name] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $headers;
|
||||
}
|
||||
|
||||
/** @param array<string, array<int, string>> $headers */
|
||||
private function sanitizeHeaders(array $headers): array
|
||||
{
|
||||
$flat = [];
|
||||
foreach ($headers as $name => $values) {
|
||||
if (strtolower($name) === 'transfer-encoding') {
|
||||
continue;
|
||||
}
|
||||
$flat[$name] = $values[0] ?? '';
|
||||
}
|
||||
|
||||
return $flat;
|
||||
}
|
||||
|
||||
private function rewriteLocation(string $location, string $slug): string
|
||||
{
|
||||
$platform = rtrim((string) config('app.platform_url', 'https://ladill.com'), '/');
|
||||
$prefix = $platform.'/q/'.$slug;
|
||||
|
||||
if (str_starts_with($location, $prefix)) {
|
||||
$suffix = substr($location, strlen($prefix));
|
||||
$rewritten = LadillLink::url($slug).$suffix;
|
||||
|
||||
return $rewritten;
|
||||
}
|
||||
|
||||
return $location;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Canonical short-link URLs for the Ladill platform (ladl.link).
|
||||
*/
|
||||
final class LadillLink
|
||||
{
|
||||
public static function baseUrl(): string
|
||||
{
|
||||
$domain = (string) config('link.public_domain', 'ladl.link');
|
||||
|
||||
return 'https://'.$domain;
|
||||
}
|
||||
|
||||
public static function url(string $slug): string
|
||||
{
|
||||
return rtrim(self::baseUrl(), '/').'/'.ltrim($slug, '/');
|
||||
}
|
||||
|
||||
public static function path(string $slug, string $suffix): string
|
||||
{
|
||||
return self::url($slug).'/'.ltrim($suffix, '/');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user