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 c57a706..4a0129e 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ 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')