From 9516fcb9f356f0f39763f39a3ed596aa1b2decc8 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 27 Jun 2026 12:16:34 +0000 Subject: [PATCH] Expand Link app with analytics, settings, and custom domains. 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 --- .gitea/workflows/deploy.yml | 13 +- REVISION | 2 +- .../Controllers/Api/SslCallbackController.php | 38 +++++ .../Controllers/Auth/SsoLoginController.php | 5 +- .../Controllers/Link/AnalyticsController.php | 26 +++ .../Link/CustomDomainController.php | 94 +++++++++++ .../Controllers/Link/SettingsController.php | 51 ++++++ .../Public/LinkRedirectController.php | 22 +-- app/Models/LinkCustomDomain.php | 57 +++++++ app/Models/ShortLink.php | 7 +- app/Models/User.php | 31 ++++ app/Services/CustomDomain/DnsResolver.php | 17 ++ .../CustomDomain/DomainsSslClient.php | 76 +++++++++ .../CustomDomain/LinkCustomDomainService.php | 115 +++++++++++++ app/Services/Link/LinkAnalyticsService.php | 101 +++++++++++ app/Services/Link/LinkPlatformProxy.php | 96 +++++++++++ app/Support/LadillLink.php | 26 +++ config/customdomain.php | 14 ++ config/identity.php | 2 +- config/ladill_launcher.php | 47 +++--- config/services.php | 2 +- ...20000_create_link_custom_domains_table.php | 33 ++++ deploy/deploy.sh | 4 +- deploy/server-bootstrap.sh | 157 ++++++++++++++++++ public/favicon.ico | Bin 11871 -> 270398 bytes resources/views/layouts/user.blade.php | 5 +- .../views/links/analytics/index.blade.php | 93 +++++++++++ resources/views/links/create.blade.php | 5 +- resources/views/links/dashboard.blade.php | 5 +- resources/views/links/domains/index.blade.php | 92 ++++++++++ resources/views/links/index.blade.php | 5 +- .../views/links/settings/index.blade.php | 48 ++++++ resources/views/links/show.blade.php | 5 +- resources/views/partials/sidebar.blade.php | 14 ++ resources/views/partials/topbar-qr.blade.php | 4 +- resources/views/search/index.blade.php | 4 +- routes/api.php | 3 +- routes/web.php | 14 +- 38 files changed, 1263 insertions(+), 70 deletions(-) create mode 100644 app/Http/Controllers/Api/SslCallbackController.php create mode 100644 app/Http/Controllers/Link/AnalyticsController.php create mode 100644 app/Http/Controllers/Link/CustomDomainController.php create mode 100644 app/Http/Controllers/Link/SettingsController.php create mode 100644 app/Models/LinkCustomDomain.php create mode 100644 app/Services/CustomDomain/DnsResolver.php create mode 100644 app/Services/CustomDomain/DomainsSslClient.php create mode 100644 app/Services/CustomDomain/LinkCustomDomainService.php create mode 100644 app/Services/Link/LinkAnalyticsService.php create mode 100644 app/Services/Link/LinkPlatformProxy.php create mode 100644 app/Support/LadillLink.php create mode 100644 config/customdomain.php create mode 100644 database/migrations/2026_06_27_120000_create_link_custom_domains_table.php create mode 100644 deploy/server-bootstrap.sh create mode 100644 resources/views/links/analytics/index.blade.php create mode 100644 resources/views/links/domains/index.blade.php create mode 100644 resources/views/links/settings/index.blade.php diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 3779644..f7f4d3d 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,16 +1,17 @@ -name: Deploy Ladill QR Plus +name: Deploy Ladill Link on: push: branches: - main + - master workflow_dispatch: permissions: contents: read concurrency: - group: deploy-qr-plus + group: deploy-link cancel-in-progress: true jobs: @@ -19,9 +20,9 @@ jobs: env: NODE_ROOT: /tmp/ladill-node-22-r1 NODE_VERSION: "22.14.0" - RELEASE_ARCHIVE: /tmp/ladill-qr-plus-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz - WORKSPACE: /tmp/${{ gitea.repository_owner }}-qr-plus-${{ gitea.run_id }}-${{ gitea.run_attempt }} - LADILL_APP_ROOT: /var/www/ladill-qr-plus + RELEASE_ARCHIVE: /tmp/ladill-link-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz + WORKSPACE: /tmp/${{ gitea.repository_owner }}-link-${{ gitea.run_id }}-${{ gitea.run_attempt }} + LADILL_APP_ROOT: /var/www/ladill-link steps: - name: Checkout shell: bash {0} @@ -78,7 +79,7 @@ jobs: - name: Deploy release shell: bash {0} env: - LADILL_RELEASE_ARCHIVE: /tmp/ladill-qr-plus-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz + LADILL_RELEASE_ARCHIVE: /tmp/ladill-link-release-${{ gitea.run_id }}-${{ gitea.run_attempt }}.tgz run: | set -Eeuo pipefail : "${LADILL_APP_ROOT:?Set LADILL_APP_ROOT}" diff --git a/REVISION b/REVISION index df4b9b8..5f7e186 100644 --- a/REVISION +++ b/REVISION @@ -1 +1 @@ -manual-deploy +d9c91ad7d852e003f460e89f50ab710c71976235 diff --git a/app/Http/Controllers/Api/SslCallbackController.php b/app/Http/Controllers/Api/SslCallbackController.php new file mode 100644 index 0000000..df09a88 --- /dev/null +++ b/app/Http/Controllers/Api/SslCallbackController.php @@ -0,0 +1,38 @@ +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']); + } +} diff --git a/app/Http/Controllers/Auth/SsoLoginController.php b/app/Http/Controllers/Auth/SsoLoginController.php index 65b4d11..2a6c6b6 100644 --- a/app/Http/Controllers/Auth/SsoLoginController.php +++ b/app/Http/Controllers/Auth/SsoLoginController.php @@ -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(); diff --git a/app/Http/Controllers/Link/AnalyticsController.php b/app/Http/Controllers/Link/AnalyticsController.php new file mode 100644 index 0000000..bc738b4 --- /dev/null +++ b/app/Http/Controllers/Link/AnalyticsController.php @@ -0,0 +1,26 @@ + $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), + ]); + } +} diff --git a/app/Http/Controllers/Link/CustomDomainController.php b/app/Http/Controllers/Link/CustomDomainController.php new file mode 100644 index 0000000..f837f2b --- /dev/null +++ b/app/Http/Controllers/Link/CustomDomainController.php @@ -0,0 +1,94 @@ + $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.'); + } +} diff --git a/app/Http/Controllers/Link/SettingsController.php b/app/Http/Controllers/Link/SettingsController.php new file mode 100644 index 0000000..8799317 --- /dev/null +++ b/app/Http/Controllers/Link/SettingsController.php @@ -0,0 +1,51 @@ + 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.'); + } +} diff --git a/app/Http/Controllers/Public/LinkRedirectController.php b/app/Http/Controllers/Public/LinkRedirectController.php index 9f13e85..9ea7237 100644 --- a/app/Http/Controllers/Public/LinkRedirectController.php +++ b/app/Http/Controllers/Public/LinkRedirectController.php @@ -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); } } diff --git a/app/Models/LinkCustomDomain.php b/app/Models/LinkCustomDomain.php new file mode 100644 index 0000000..111af00 --- /dev/null +++ b/app/Models/LinkCustomDomain.php @@ -0,0 +1,57 @@ + '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; + }); + } +} diff --git a/app/Models/ShortLink.php b/app/Models/ShortLink.php index 563c032..c871ccc 100644 --- a/app/Models/ShortLink.php +++ b/app/Models/ShortLink.php @@ -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 diff --git a/app/Models/User.php b/app/Models/User.php index 015e40d..57b1f32 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ + 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( diff --git a/app/Services/CustomDomain/DnsResolver.php b/app/Services/CustomDomain/DnsResolver.php new file mode 100644 index 0000000..77d8029 --- /dev/null +++ b/app/Services/CustomDomain/DnsResolver.php @@ -0,0 +1,17 @@ + */ + 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))); + } +} diff --git a/app/Services/CustomDomain/DomainsSslClient.php b/app/Services/CustomDomain/DomainsSslClient.php new file mode 100644 index 0000000..aa4b3c2 --- /dev/null +++ b/app/Services/CustomDomain/DomainsSslClient.php @@ -0,0 +1,76 @@ +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()]); + } + } +} diff --git a/app/Services/CustomDomain/LinkCustomDomainService.php b/app/Services/CustomDomain/LinkCustomDomainService.php new file mode 100644 index 0000000..eda23b6 --- /dev/null +++ b/app/Services/CustomDomain/LinkCustomDomainService.php @@ -0,0 +1,115 @@ +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); + } +} diff --git a/app/Services/Link/LinkAnalyticsService.php b/app/Services/Link/LinkAnalyticsService.php new file mode 100644 index 0000000..d9fc03e --- /dev/null +++ b/app/Services/Link/LinkAnalyticsService.php @@ -0,0 +1,101 @@ +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 */ + 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 */ + 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 */ + 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 */ + 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; + } +} diff --git a/app/Services/Link/LinkPlatformProxy.php b/app/Services/Link/LinkPlatformProxy.php new file mode 100644 index 0000000..2cce2c4 --- /dev/null +++ b/app/Services/Link/LinkPlatformProxy.php @@ -0,0 +1,96 @@ +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 */ + 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> $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; + } +} diff --git a/app/Support/LadillLink.php b/app/Support/LadillLink.php new file mode 100644 index 0000000..3a51bc0 --- /dev/null +++ b/app/Support/LadillLink.php @@ -0,0 +1,26 @@ + filter_var(env('CUSTOM_DOMAINS_ENABLED', true), FILTER_VALIDATE_BOOLEAN), + 'app_host' => $appHost, + 'public_host' => env('LINK_PUBLIC_DOMAIN', 'ladl.link'), + 'server_ip' => env('LADILL_APP_SERVER_IP', '161.97.138.149'), + 'ssl_api_url' => rtrim(env('DOMAINS_SSL_API_URL', 'https://domains.ladill.com/api'), '/'), + 'ssl_api_key' => env('DOMAINS_API_KEY_LINK', ''), + 'nginx_include' => env('CUSTOM_DOMAINS_NGINX_INCLUDE', '/etc/nginx/snippets/ladill-link-app.conf'), + 'callback_secret' => env('SSL_CALLBACK_SECRET', ''), +]; diff --git a/config/identity.php b/config/identity.php index 99b3199..d4835aa 100644 --- a/config/identity.php +++ b/config/identity.php @@ -2,5 +2,5 @@ return [ 'api_url' => env('IDENTITY_API_URL', 'https://ladill.com/api'), - 'api_key' => env('IDENTITY_API_KEY_HOSTING'), + 'api_key' => env('IDENTITY_API_KEY_LINK'), ]; diff --git a/config/ladill_launcher.php b/config/ladill_launcher.php index 058c8c4..14a3c31 100644 --- a/config/ladill_launcher.php +++ b/config/ladill_launcher.php @@ -2,44 +2,39 @@ /* |-------------------------------------------------------------------------- -| Ladill App Launcher — SHARED, IDENTICAL across every app/service repo +| Ladill App Launcher — GENERATED, IDENTICAL across every app/service repo |-------------------------------------------------------------------------- | -| Lists ONLY fully-extracted apps (each on its own subdomain). To replicate the -| launcher in a new app, copy these three things verbatim into that repo: -| - config/ladill_launcher.php (this file) -| - resources/views/partials/launcher.blade.php -| - public/images/launcher-icons/* (the icon set) +| DO NOT EDIT BY HAND. This file is generated from the app registry in the +| monolith (config/ladill_apps.php). To change the launcher, edit that +| registry and run: php artisan ladill:launcher:sync --propagate | -| When a service becomes FULLY extracted: add one row below AND drop its icon in -| public/images/launcher-icons/ — in EVERY repo. That's the only edit needed. -| Do NOT list a service here until it is fully extracted to its own subdomain. -| -| URLs are absolute (built from the platform root) so this file is byte-identical -| in every app; the blade omits whichever row matches the app's APP_URL host. -| Icons are served from /images/launcher-icons/. +| URLs are absolute (built from the platform root) so this file is +| byte-identical in every repo; the blade omits whichever row matches the +| app's own host. Icons are served from /images/launcher-icons/. */ $root = config('app.platform_domain', 'ladill.com'); return [ 'apps' => [ - ['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'], - ['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'], - ['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'], + ['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'], + ['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'], + ['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'], + ['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'], + ['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'], + ['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'], + ['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'], + ['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'], + ['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'], + ['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'], + ['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'], ['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'], + ['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'], + ['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'], + ['name' => 'Email', 'url' => 'https://email.'.$root, 'icon' => 'email.svg'], ['name' => 'Domains', 'url' => 'https://domains.'.$root, 'icon' => 'domains.svg'], ['name' => 'Servers', 'url' => 'https://servers.'.$root, 'icon' => 'servers.svg'], ['name' => 'Hosting', 'url' => 'https://hosting.'.$root, 'icon' => 'hosting.svg'], - ['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'], - ['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'], - ['name' => 'Mini', 'url' => 'https://mini.'.$root.'/sso/connect?redirect='.urlencode('https://mini.'.$root.'/dashboard'), 'icon' => 'mini.svg'], - ['name' => 'Invoice', 'url' => 'https://invoice.'.$root.'/sso/connect?redirect='.urlencode('https://invoice.'.$root.'/dashboard'), 'icon' => 'invoice.svg'], - ['name' => 'Give', 'url' => 'https://give.'.$root.'/sso/connect?redirect='.urlencode('https://give.'.$root.'/dashboard'), 'icon' => 'give.svg'], - ['name' => 'Merchant', 'url' => 'https://merchant.'.$root.'/sso/connect?redirect='.urlencode('https://merchant.'.$root.'/dashboard'), 'icon' => 'merchant.svg'], - ['name' => 'POS', 'url' => 'https://pos.'.$root.'/sso/connect?redirect='.urlencode('https://pos.'.$root.'/dashboard'), 'icon' => 'pos.svg'], - ['name' => 'Transfer', 'url' => 'https://transfer.'.$root.'/sso/connect?redirect='.urlencode('https://transfer.'.$root.'/dashboard'), 'icon' => 'transfer.svg'], - ['name' => 'CRM', 'url' => 'https://crm.'.$root.'/sso/connect?redirect='.urlencode('https://crm.'.$root.'/dashboard'), 'icon' => 'crm.svg'], - ['name' => 'Accounting', 'url' => 'https://accounting.'.$root.'/sso/connect?redirect='.urlencode('https://accounting.'.$root.'/dashboard'), 'icon' => 'accounting.svg'], ], ]; diff --git a/config/services.php b/config/services.php index 06d2548..401c6ed 100644 --- a/config/services.php +++ b/config/services.php @@ -23,7 +23,7 @@ return [ 'issuer' => 'https://'.config('app.auth_domain'), 'client_id' => env('LADILL_SSO_CLIENT_ID'), 'client_secret' => env('LADILL_SSO_CLIENT_SECRET'), - 'redirect' => rtrim((string) env('APP_URL', 'https://qrplus.ladill.com'), '/').'/sso/callback', + 'redirect' => rtrim((string) env('APP_URL', 'https://link.ladill.com'), '/').'/sso/callback', ], 'ladill_webmail' => [ diff --git a/database/migrations/2026_06_27_120000_create_link_custom_domains_table.php b/database/migrations/2026_06_27_120000_create_link_custom_domains_table.php new file mode 100644 index 0000000..57befc5 --- /dev/null +++ b/database/migrations/2026_06_27_120000_create_link_custom_domains_table.php @@ -0,0 +1,33 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('host')->unique(); + $table->boolean('include_www')->default(true); + $table->boolean('is_default')->default(false); + $table->string('status', 16)->default('pending'); + $table->string('ssl_status', 16)->default('pending'); + $table->timestamp('dns_verified_at')->nullable(); + $table->timestamp('ssl_issued_at')->nullable(); + $table->timestamp('ssl_expires_at')->nullable(); + $table->text('last_error')->nullable(); + $table->timestamps(); + + $table->index(['user_id', 'is_default']); + }); + } + + public function down(): void + { + Schema::dropIfExists('link_custom_domains'); + } +}; diff --git a/deploy/deploy.sh b/deploy/deploy.sh index a8ab951..61386df 100755 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -2,7 +2,7 @@ # Fast on-host release deploy (same model as climpme/web/deploy/deploy.sh). set -Eeuo pipefail -APP_ROOT="${LADILL_APP_ROOT:-/var/www/ladill-qr-plus}" +APP_ROOT="${LADILL_APP_ROOT:-/var/www/ladill-link}" RELEASES_DIR="$APP_ROOT/releases" SHARED_DIR="$APP_ROOT/shared" CURRENT_LINK="$APP_ROOT/current" @@ -236,7 +236,7 @@ if [ -L "$CURRENT_LINK" ] && [ -f "$CURRENT_LINK/artisan" ]; then (cd "$CURRENT_LINK" && php artisan queue:restart) || true fi if command -v supervisorctl >/dev/null 2>&1; then - supervisorctl restart ladill-qr-plus-worker:* 2>/dev/null || true + supervisorctl restart ladill-link-worker:* 2>/dev/null || true fi log "Cleaning old releases" diff --git a/deploy/server-bootstrap.sh b/deploy/server-bootstrap.sh new file mode 100644 index 0000000..838a384 --- /dev/null +++ b/deploy/server-bootstrap.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# One-time server bootstrap for Ladill Link on 161.97.138.149 +set -Eeuo pipefail + +APP_ROOT="/var/www/ladill-link" +MONOLITH="/var/www/ladill.com/current" +QR_PLUS_ENV="/var/www/ladill-qr-plus/shared/.env" +NGINX_SETUP="/var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh" +RELEASE_ARCHIVE="${1:-/tmp/ladill-link-release.tgz}" + +log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; } + +[ -f "$RELEASE_ARCHIVE" ] || { echo "Missing release archive: $RELEASE_ARCHIVE" >&2; exit 1; } +[ -f "$QR_PLUS_ENV" ] || { echo "Missing qr-plus env template" >&2; exit 1; } + +log "Creating app directories" +mkdir -p "$APP_ROOT"/{releases,shared} +chown -R deploy:www-data "$APP_ROOT" 2>/dev/null || true + +if [ ! -f "$APP_ROOT/shared/.env" ]; then + log "Bootstrapping .env from qr-plus template" + cp "$QR_PLUS_ENV" "$APP_ROOT/shared/.env" + sed -i \ + -e 's/^APP_NAME=.*/APP_NAME="Ladill Link"/' \ + -e 's#^APP_URL=.*#APP_URL=https://link.ladill.com#' \ + -e 's/^DB_DATABASE=.*/DB_DATABASE=ladill_link/' \ + -e 's/^DB_USERNAME=.*/DB_USERNAME=ladill_link/' \ + -e 's/^BILLING_API_KEY_QR=.*/BILLING_API_KEY_LINK=/' \ + "$APP_ROOT/shared/.env" + # Generate DB password + billing key if missing + DB_PASS="$(openssl rand -hex 16)" + BILL_KEY="$(openssl rand -hex 24)" + sed -i "s/^DB_PASSWORD=.*/DB_PASSWORD=${DB_PASS}/" "$APP_ROOT/shared/.env" + if ! grep -q '^BILLING_API_KEY_LINK=' "$APP_ROOT/shared/.env"; then + echo "BILLING_API_KEY_LINK=${BILL_KEY}" >> "$APP_ROOT/shared/.env" + else + sed -i "s/^BILLING_API_KEY_LINK=.*/BILLING_API_KEY_LINK=${BILL_KEY}/" "$APP_ROOT/shared/.env" + fi + grep -q '^LINK_PUBLIC_DOMAIN=' "$APP_ROOT/shared/.env" || echo 'LINK_PUBLIC_DOMAIN=ladl.link' >> "$APP_ROOT/shared/.env" + grep -q '^LINK_PRICE_PER_LINK_GHS=' "$APP_ROOT/shared/.env" || echo 'LINK_PRICE_PER_LINK_GHS=0.05' >> "$APP_ROOT/shared/.env" + grep -q '^IDENTITY_API_KEY_LINK=' "$APP_ROOT/shared/.env" || echo "IDENTITY_API_KEY_LINK=$(openssl rand -hex 24)" >> "$APP_ROOT/shared/.env" + chmod 640 "$APP_ROOT/shared/.env" + chown deploy:www-data "$APP_ROOT/shared/.env" 2>/dev/null || true + + log "Creating MySQL database ladill_link" + mysql -e "CREATE DATABASE IF NOT EXISTS ladill_link CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;" + mysql -e "CREATE USER IF NOT EXISTS 'ladill_link'@'127.0.0.1' IDENTIFIED BY '${DB_PASS}';" + mysql -e "GRANT ALL PRIVILEGES ON ladill_link.* TO 'ladill_link'@'127.0.0.1';" + mysql -e "FLUSH PRIVILEGES;" + + # Save keys for monolith wiring (picked up below) + echo "$BILL_KEY" > /root/.ladill-link-bootstrap-billing-key + echo "$DB_PASS" > /root/.ladill-link-bootstrap-db-pass +fi + +log "Deploying release archive" +export LADILL_APP_ROOT="$APP_ROOT" +export LADILL_RELEASE_ARCHIVE="$RELEASE_ARCHIVE" +bash "$APP_ROOT/releases/bootstrap-deploy.sh" 2>/dev/null || true + +# deploy.sh lives inside the archive — extract deploy helper first if needed +TMP_EXTRACT="/tmp/ladill-link-deploy-$$" +mkdir -p "$TMP_EXTRACT" +tar -xzf "$RELEASE_ARCHIVE" -C "$TMP_EXTRACT" deploy/deploy.sh +cp "$TMP_EXTRACT/deploy/deploy.sh" /tmp/ladill-link-deploy.sh +LADILL_APP_ROOT="$APP_ROOT" LADILL_RELEASE_ARCHIVE="$RELEASE_ARCHIVE" bash /tmp/ladill-link-deploy.sh + +log "Configuring nginx for link.ladill.com" +if [ -x "$NGINX_SETUP" ]; then + bash "$NGINX_SETUP" link --app "$APP_ROOT/current" +else + bash /var/www/ladill.com/current/deployment/setup-service-subdomain-nginx.sh link --app "$APP_ROOT/current" 2>/dev/null || \ + echo "WARN: run setup-service-subdomain-nginx.sh link manually" +fi + +log "Configuring nginx for ladl.link" +LADL_CONF="/etc/nginx/sites-available/ladl.link.conf" +if [ ! -f "$LADL_CONF" ]; then + cat > "$LADL_CONF" <<'NGINX' +server { + listen 80; + listen [::]:80; + server_name ladl.link www.ladl.link; + root /var/www/ladill-link/current/public; + location ^~ /.well-known/acme-challenge/ { allow all; } + location / { try_files $uri $uri/ /index.php?$query_string; } + location ~ \.php$ { + include snippets/fastcgi-php.conf; + fastcgi_pass unix:/run/php/php8.4-fpm-ladill.sock; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + } +} +NGINX + ln -sf "$LADL_CONF" /etc/nginx/sites-enabled/ladl.link.conf + nginx -t && systemctl reload nginx + certbot certonly --webroot -w /var/www/ladill-link/current/public -d ladl.link -d www.ladl.link --non-interactive --agree-tos -m admin@ladill.com 2>/dev/null || \ + certbot certonly --nginx -d ladl.link -d www.ladl.link --non-interactive --agree-tos -m admin@ladill.com 2>/dev/null || \ + log "WARN: certbot for ladl.link failed — DNS may not point here yet" + + if [ -d /etc/letsencrypt/live/ladl.link ]; then + cat > "$LADL_CONF" <<'NGINX' +server { + listen 80; + listen [::]:80; + server_name ladl.link www.ladl.link; + location ^~ /.well-known/acme-challenge/ { root /var/www/ladill-link/current/public; allow all; } + location / { return 301 https://$host$request_uri; } +} +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name ladl.link www.ladl.link; + client_max_body_size 64M; + ssl_certificate /etc/letsencrypt/live/ladl.link/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/ladl.link/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + root /var/www/ladill-link/current/public; + index index.php; + location / { try_files $uri $uri/ /index.php?$query_string; } + location ~ \.php$ { + include snippets/fastcgi-php.conf; + fastcgi_pass unix:/run/php/php8.4-fpm-ladill.sock; + fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; + } + location ~ /\.(?!well-known).* { deny all; } +} +NGINX + nginx -t && systemctl reload nginx + fi +fi + +log "Wiring monolith billing + link DB (if monolith has link registry)" +if [ -f "$MONOLITH/artisan" ]; then + BILL_KEY="$(cat /root/.ladill-link-bootstrap-billing-key 2>/dev/null || grep '^BILLING_API_KEY_LINK=' "$APP_ROOT/shared/.env" | cut -d= -f2-)" + MONO_ENV="/var/www/ladill.com/shared/.env" + if [ -n "$BILL_KEY" ] && [ -f "$MONO_ENV" ]; then + grep -q '^BILLING_API_KEY_LINK=' "$MONO_ENV" || echo "BILLING_API_KEY_LINK=${BILL_KEY}" >> "$MONO_ENV" + grep -q '^LINK_DB_DATABASE=' "$MONO_ENV" || cat >> "$MONO_ENV" </dev/null; then + (cd "$MONOLITH" && php artisan ladill:onboard-app link --run-dns) || true + (cd "$MONOLITH" && php artisan dns:sync-subdomains) || true + else + log "Monolith missing link registry — deploy monolith code then re-run onboard" + fi +fi + +log "Bootstrap complete" +curl -sI "https://link.ladill.com/up" 2>/dev/null | head -3 || curl -sI "http://link.ladill.com/up" 2>/dev/null | head -3 || true diff --git a/public/favicon.ico b/public/favicon.ico index c57a706d91d657e9bbc9102283e33c94e250e336..4a0129e9104f16658232364e66fe2c49c002a148 100644 GIT binary patch literal 270398 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%0Dixaw&Hll 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 Z{|`L0P|Ygv5E1|Y002ovPDHLkV1lR^_!s~H diff --git a/resources/views/layouts/user.blade.php b/resources/views/layouts/user.blade.php index a664aab..0b72d2c 100644 --- a/resources/views/layouts/user.blade.php +++ b/resources/views/layouts/user.blade.php @@ -1,3 +1,4 @@ +@props(['title' => 'Dashboard']) @php $mobileFullScreenPage = false; @endphp @@ -8,7 +9,7 @@ @include('partials.favicon') - {{ $title ?? 'Dashboard' }} - Ladill Link + {{ $title }} - Ladill Link @vite(['resources/css/app.css', 'resources/js/app.js']) @@ -239,7 +240,7 @@ document.addEventListener('alpine:init', () => { }); -@include('partials.afia') + @include('partials.sso-keepalive') @include('partials.confirm-prompt') diff --git a/resources/views/links/analytics/index.blade.php b/resources/views/links/analytics/index.blade.php new file mode 100644 index 0000000..105c32f --- /dev/null +++ b/resources/views/links/analytics/index.blade.php @@ -0,0 +1,93 @@ + + Analytics +
+
+

Analytics

+

Click activity across all your short links.

+
+ +
+
+

{{ number_format($summary['total_clicks']) }}

+

Total clicks

+
+
+

{{ number_format($summary['unique_clicks']) }}

+

Unique clicks

+
+
+

{{ number_format($summary['clicks_7d']) }}

+

Last 7 days

+
+
+

{{ number_format($summary['clicks_30d']) }}

+

Last 30 days

+
+
+

{{ number_format($summary['links_total']) }}

+

Links created

+
+
+ +
+

Clicks — last 30 days

+ @php $maxDaily = max(1, $dailyClicks->max('total')); @endphp +
+ @foreach($dailyClicks as $day) +
+
+
+ @endforeach +
+
+ +
+
+

Top links

+
    + @forelse($topLinks as $row) +
  • + {{ $row['label'] }} + {{ number_format($row['total']) }} +
  • + @empty +
  • No clicks yet
  • + @endforelse +
+
+
+

Top referrers

+
    + @forelse($referrers as $row) +
  • + {{ $row['label'] }} + {{ number_format($row['total']) }} +
  • + @empty +
  • No referrer data yet
  • + @endforelse +
+
+
+ +
+
+

Recent clicks

+
+
    + @forelse($recentClicks as $click) +
  • +
    +

    {{ $click->shortLink?->label ?: $click->shortLink?->slug }}

    +

    {{ $click->referer ?: 'Direct / unknown' }}

    +
    + +
  • + @empty +
  • No clicks recorded yet.
  • + @endforelse +
+
+
+
diff --git a/resources/views/links/create.blade.php b/resources/views/links/create.blade.php index 239aff8..e43147d 100644 --- a/resources/views/links/create.blade.php +++ b/resources/views/links/create.blade.php @@ -1,4 +1,5 @@ - + + Create Link

Create short link

@@ -50,4 +51,4 @@
-
+ diff --git a/resources/views/links/dashboard.blade.php b/resources/views/links/dashboard.blade.php index 9d3c939..c5a015c 100644 --- a/resources/views/links/dashboard.blade.php +++ b/resources/views/links/dashboard.blade.php @@ -1,4 +1,5 @@ - + + Dashboard
@@ -47,4 +48,4 @@
@endif
- + diff --git a/resources/views/links/domains/index.blade.php b/resources/views/links/domains/index.blade.php new file mode 100644 index 0000000..a1d31fd --- /dev/null +++ b/resources/views/links/domains/index.blade.php @@ -0,0 +1,92 @@ + + Custom Domains +
+
+

Custom Domains

+

Use your own domain for short links — like Bitly branded domains. Point DNS to Ladill, we handle SSL.

+
+ + @unless($enabled) +
+ Custom domains are not configured on this environment yet. Contact support to enable the Domains API key. +
+ @endunless + + @if($enabled) +
+

Connect a domain

+

Example: go.yourbrand.com → short links like go.yourbrand.com/summer-sale

+
+ @csrf +
+ + + @error('host')

{{ $message }}

@enderror +
+ + +

Add an A record for your domain pointing to {{ $serverIp }}, then click Verify.

+ +
+
+ @endif + +
+
+

Your domains

+
+ @if($domains->isEmpty()) +

No custom domains yet. Your links use {{ $publicDomain }} by default.

+ @else +
    + @foreach($domains as $domain) +
  • +
    +
    +

    {{ $domain->host }}

    +

    + DNS: {{ $domain->dns_verified_at ? 'verified' : 'pending' }} · + SSL: {{ $domain->ssl_status }} · + @if($domain->is_default)Default@endif +

    + @if($domain->last_error) +

    {{ $domain->last_error }}

    + @endif +
    +
    + @if($enabled && $domain->ssl_status !== 'active') +
    + @csrf + +
    + @endif + @if($domain->isLive() && ! $domain->is_default) +
    + @csrf + +
    + @endif +
    + @csrf @method('DELETE') + +
    +
    +
    +
  • + @endforeach +
+ @endif +
+ +

+ Domains also appear in Ladill Domains once connected. +

+
+
diff --git a/resources/views/links/index.blade.php b/resources/views/links/index.blade.php index b857854..e89febc 100644 --- a/resources/views/links/index.blade.php +++ b/resources/views/links/index.blade.php @@ -1,4 +1,5 @@ - + + My Links
@@ -52,4 +53,4 @@ @endif
- + diff --git a/resources/views/links/settings/index.blade.php b/resources/views/links/settings/index.blade.php new file mode 100644 index 0000000..5627eb5 --- /dev/null +++ b/resources/views/links/settings/index.blade.php @@ -0,0 +1,48 @@ + + Settings +
+
+

Settings

+

Manage your Ladill Link preferences.

+
+ +
+

Default short-link domain

+

Choose which domain new links are shown on. Custom domains must be verified first.

+
+ @csrf @method('PUT') + + @foreach($customDomains->where('status', 'active')->where('ssl_status', 'active') as $domain) + + @endforeach + +
+

+ Manage custom domains → +

+
+ +
+

Billing

+

GHS {{ number_format($pricePerLink, 2) }} per link created. Charged to your Ladill wallet.

+ +
+ +
+

Ladill Account

+

Profile, security, and platform settings live on your Ladill account.

+ + Open account settings + +
+
+
diff --git a/resources/views/links/show.blade.php b/resources/views/links/show.blade.php index d73f5f2..9d8c230 100644 --- a/resources/views/links/show.blade.php +++ b/resources/views/links/show.blade.php @@ -1,4 +1,5 @@ - + + {{ $link->label ?: $link->slug }}
@@ -55,4 +56,4 @@
- + diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 23e5c71..243453a 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -10,6 +10,10 @@ 'icon' => ''], ['name' => 'My Links', 'route' => route('user.links.index'), 'active' => request()->routeIs('user.links.*'), 'icon' => ''], + ['name' => 'Analytics', 'route' => route('link.analytics.index'), 'active' => request()->routeIs('link.analytics.*'), + 'icon' => ''], + ['name' => 'Custom Domains', 'route' => route('link.domains.index'), 'active' => request()->routeIs('link.domains.*'), + 'icon' => ''], ]; @endphp + +
diff --git a/resources/views/partials/topbar-qr.blade.php b/resources/views/partials/topbar-qr.blade.php index c440d97..a3e9426 100644 --- a/resources/views/partials/topbar-qr.blade.php +++ b/resources/views/partials/topbar-qr.blade.php @@ -14,7 +14,7 @@ @include('partials.mobile-topbar-title')