site updates
This commit is contained in:
@@ -3,8 +3,8 @@
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseActivation;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Modules\Licensing\Models\LicenseActivation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
|
||||
@@ -27,7 +27,17 @@ final class CmsPageController extends Controller
|
||||
'body' => ['required','string'],
|
||||
'meta_title' => ['nullable','string','max:160'],
|
||||
'meta_description' => ['nullable','string','max:320'],
|
||||
'meta_keywords' => ['nullable','string','max:500'],
|
||||
'canonical_url' => ['nullable','url:http,https','max:2048'],
|
||||
'og_title' => ['nullable','string','max:160'],
|
||||
'og_description' => ['nullable','string','max:320'],
|
||||
'og_image' => ['nullable','url:http,https','max:2048'],
|
||||
'sitemap_priority' => ['required','numeric','min:0','max:1'],
|
||||
'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
|
||||
]);
|
||||
$data['robots_index'] = $request->boolean('robots_index');
|
||||
$data['robots_follow'] = $request->boolean('robots_follow');
|
||||
$data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
|
||||
return $data;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
final class SeoSettingsController extends Controller
|
||||
{
|
||||
private const KEYS = [
|
||||
'seo_site_name', 'seo_title_suffix', 'seo_default_title', 'seo_default_description',
|
||||
'seo_default_keywords', 'seo_canonical_base_url', 'seo_default_og_image', 'seo_twitter_site',
|
||||
'seo_organization_name', 'seo_organization_logo', 'seo_google_site_verification',
|
||||
];
|
||||
|
||||
public function edit(): View
|
||||
{
|
||||
return view('admin.seo.edit', [
|
||||
'settings' => collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
|
||||
'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'seo_site_name' => ['required', 'string', 'max:80'],
|
||||
'seo_title_suffix' => ['nullable', 'string', 'max:80'],
|
||||
'seo_default_title' => ['required', 'string', 'max:160'],
|
||||
'seo_default_description' => ['required', 'string', 'max:320'],
|
||||
'seo_default_keywords' => ['nullable', 'string', 'max:500'],
|
||||
'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
|
||||
'seo_organization_name' => ['nullable', 'string', 'max:120'],
|
||||
'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
foreach (self::KEYS as $key) {
|
||||
SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
|
||||
}
|
||||
SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
|
||||
|
||||
return back()->with('status', 'SEO settings updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Modules\Licensing\Models\LicenseActivation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = User::query()->latest();
|
||||
|
||||
if ($search = trim((string) $request->query('q'))) {
|
||||
$query->where(fn ($q) => $q
|
||||
->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%"));
|
||||
}
|
||||
|
||||
if ($request->query('status') === 'admin') {
|
||||
$query->where('is_admin', true);
|
||||
} elseif ($request->query('status') === 'suspended') {
|
||||
$query->whereNotNull('disabled_at');
|
||||
} elseif ($request->query('status') === 'unverified') {
|
||||
$query->whereNull('email_verified_at');
|
||||
}
|
||||
|
||||
$users = $query->paginate(50)->withQueryString();
|
||||
$emails = $users->getCollection()->pluck('email')->map(fn ($email) => strtolower($email))->all();
|
||||
$licenseCounts = License::query()
|
||||
->selectRaw('LOWER(customer_email) as email_key, COUNT(*) as aggregate')
|
||||
->whereIn(DB::raw('LOWER(customer_email)'), $emails)
|
||||
->groupBy(DB::raw('LOWER(customer_email)'))
|
||||
->pluck('aggregate', 'email_key');
|
||||
|
||||
return view('admin.users.index', compact('users', 'licenseCounts'));
|
||||
}
|
||||
|
||||
public function show(User $user)
|
||||
{
|
||||
$licenses = License::with(['product', 'activations'])
|
||||
->whereRaw('LOWER(customer_email) = ?', [strtolower($user->email)])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return view('admin.users.show', compact('user', 'licenses'));
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
||||
'is_admin' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$oldEmail = strtolower($user->email);
|
||||
$newEmail = strtolower(trim($data['email']));
|
||||
$isSelf = $request->user()->is($user);
|
||||
|
||||
DB::transaction(function () use ($user, $data, $oldEmail, $newEmail, $isSelf): void {
|
||||
$user->name = trim($data['name']);
|
||||
$user->email = $newEmail;
|
||||
if (! $isSelf) {
|
||||
$user->is_admin = (bool) ($data['is_admin'] ?? false);
|
||||
}
|
||||
$user->save();
|
||||
|
||||
if ($oldEmail !== $newEmail) {
|
||||
License::whereRaw('LOWER(customer_email) = ?', [$oldEmail])
|
||||
->update(['customer_email' => $newEmail]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('admin.users.show', $user)->with('status', 'User updated.');
|
||||
}
|
||||
|
||||
public function verify(User $user)
|
||||
{
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
$user->markEmailAsVerified();
|
||||
}
|
||||
|
||||
return back()->with('status', 'Email marked as verified.');
|
||||
}
|
||||
|
||||
public function resendVerification(User $user)
|
||||
{
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
|
||||
return back()->with('status', 'Verification email sent.');
|
||||
}
|
||||
|
||||
public function sendPasswordReset(User $user)
|
||||
{
|
||||
$status = Password::sendResetLink(['email' => $user->email]);
|
||||
|
||||
return $status === Password::RESET_LINK_SENT
|
||||
? back()->with('status', 'Password reset link sent.')
|
||||
: back()->withErrors(['email' => __($status)]);
|
||||
}
|
||||
|
||||
public function suspend(Request $request, User $user)
|
||||
{
|
||||
abort_if($request->user()->is($user), 422, 'You cannot suspend your own administrator account.');
|
||||
$user->forceFill(['disabled_at' => now(), 'remember_token' => null])->save();
|
||||
|
||||
return back()->with('status', 'User suspended.');
|
||||
}
|
||||
|
||||
public function restore(User $user)
|
||||
{
|
||||
$user->forceFill(['disabled_at' => null])->save();
|
||||
|
||||
return back()->with('status', 'User restored.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, User $user)
|
||||
{
|
||||
abort_if($request->user()->is($user), 422, 'You cannot delete your own administrator account.');
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('admin.users.index')->with('status', 'User deleted. Licences were retained.');
|
||||
}
|
||||
|
||||
public function updateLicense(Request $request, User $user, License $license)
|
||||
{
|
||||
$this->ensureOwnsLicense($user, $license);
|
||||
$data = $request->validate(['activation_limit' => ['required', 'integer', 'min:1', 'max:1000']]);
|
||||
$license->update(['activation_limit' => $data['activation_limit']]);
|
||||
|
||||
return back()->with('status', 'Activation limit updated.');
|
||||
}
|
||||
|
||||
public function deactivate(User $user, License $license, LicenseActivation $activation)
|
||||
{
|
||||
$this->ensureOwnsLicense($user, $license);
|
||||
abort_unless((string) $activation->license_id === (string) $license->id, 404);
|
||||
$activation->update(['deactivated_at' => now()]);
|
||||
|
||||
return back()->with('status', 'Device activation released.');
|
||||
}
|
||||
|
||||
private function ensureOwnsLicense(User $user, License $license): void
|
||||
{
|
||||
abort_unless(strtolower((string) $license->customer_email) === strtolower($user->email), 404);
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseActivation;
|
||||
use App\Services\Licensing\LicenseKey;
|
||||
use App\Services\Licensing\LicenseSigner;
|
||||
use App\Services\Licensing\LicenseTokenVerifier;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
final class LicenseActivationController extends Controller
|
||||
{
|
||||
public function activate(Request $request, LicenseSigner $signer): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'licenseKey' => ['required', 'string', 'max:128'],
|
||||
'installationID' => ['required', 'uuid'],
|
||||
'appVersion' => ['required', 'string', 'max:32'],
|
||||
'platform' => ['required', 'string', 'max:32'],
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($data, $signer) {
|
||||
$license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $license) {
|
||||
throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']);
|
||||
}
|
||||
|
||||
if (! $license->is_usable) {
|
||||
return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
|
||||
}
|
||||
|
||||
$installationID = strtolower($data['installationID']);
|
||||
$activation = LicenseActivation::where('license_id', $license->id)
|
||||
->where('installation_id', $installationID)
|
||||
->first();
|
||||
|
||||
if ($activation && $activation->deactivated_at === null) {
|
||||
$activation->update([
|
||||
'last_seen_at' => now(),
|
||||
'platform' => $data['platform'],
|
||||
'app_version' => $data['appVersion'],
|
||||
]);
|
||||
} else {
|
||||
$activeCount = LicenseActivation::where('license_id', $license->id)
|
||||
->whereNull('deactivated_at')
|
||||
->count();
|
||||
|
||||
if ($activeCount >= $license->activation_limit) {
|
||||
return response()->json(['message' => 'This licence has reached its activation limit. Deactivate another Mac first.'], 409);
|
||||
}
|
||||
|
||||
if ($activation) {
|
||||
$activation->update([
|
||||
'deactivated_at' => null,
|
||||
'activated_at' => now(),
|
||||
'last_seen_at' => now(),
|
||||
'platform' => $data['platform'],
|
||||
'app_version' => $data['appVersion'],
|
||||
]);
|
||||
} else {
|
||||
LicenseActivation::create([
|
||||
'license_id' => $license->id,
|
||||
'installation_id' => $installationID,
|
||||
'platform' => $data['platform'],
|
||||
'app_version' => $data['appVersion'],
|
||||
'activated_at' => now(),
|
||||
'last_seen_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'licenseToken' => $signer->sign($license, $data['installationID']),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function validateInstallation(
|
||||
Request $request,
|
||||
LicenseTokenVerifier $verifier,
|
||||
LicenseSigner $signer,
|
||||
): JsonResponse {
|
||||
$data = $request->validate([
|
||||
'licenseToken' => ['required', 'string', 'max:8192'],
|
||||
'installationID' => ['required', 'uuid'],
|
||||
'appVersion' => ['required', 'string', 'max:32'],
|
||||
'platform' => ['required', 'string', 'max:32'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$payload = $verifier->verify($data['licenseToken']);
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'The licence token is invalid.'], 400);
|
||||
}
|
||||
|
||||
if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
|
||||
return response()->json(['message' => 'The installation does not match this licence token.'], 400);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($data, $payload, $signer) {
|
||||
$license = License::whereKey((string) ($payload['licenseID'] ?? ''))
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $license || ! $license->is_usable) {
|
||||
return response()->json(['message' => 'This licence is expired or has been revoked.'], 403);
|
||||
}
|
||||
|
||||
$activation = LicenseActivation::where('license_id', $license->id)
|
||||
->where('installation_id', strtolower($data['installationID']))
|
||||
->first();
|
||||
|
||||
if (! $activation || $activation->deactivated_at !== null) {
|
||||
return response()->json(['message' => 'This Mac has been deactivated from the licence portal.'], 403);
|
||||
}
|
||||
|
||||
$activation->update([
|
||||
'last_seen_at' => now(),
|
||||
'platform' => $data['platform'],
|
||||
'app_version' => $data['appVersion'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'active' => true,
|
||||
// Refreshing the signed token also applies subscription edition/expiry changes
|
||||
// without requiring the user to reactivate manually.
|
||||
'licenseToken' => $signer->sign($license, $data['installationID']),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function deactivate(Request $request, LicenseTokenVerifier $verifier): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'licenseToken' => ['required', 'string', 'max:8192'],
|
||||
'installationID' => ['required', 'uuid'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$payload = $verifier->verify($data['licenseToken']);
|
||||
} catch (\Throwable) {
|
||||
return response()->json(['message' => 'The licence token is invalid.'], 400);
|
||||
}
|
||||
|
||||
if (strcasecmp((string) ($payload['installationID'] ?? ''), $data['installationID']) !== 0) {
|
||||
return response()->json(['message' => 'The installation does not match this licence token.'], 400);
|
||||
}
|
||||
|
||||
LicenseActivation::where('license_id', (string) ($payload['licenseID'] ?? ''))
|
||||
->where('installation_id', strtolower($data['installationID']))
|
||||
->whereNull('deactivated_at')
|
||||
->update(['deactivated_at' => now(), 'last_seen_at' => now()]);
|
||||
|
||||
return response()->json((object) []);
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\LicenseWebhookEvent;
|
||||
use App\Services\Licensing\LicenseDeliveryService;
|
||||
use App\Services\Payments\LemonSqueezyWebhookVerifier;
|
||||
use App\Services\Payments\PaddleWebhookVerifier;
|
||||
use App\Services\Payments\PurchaseProvisioner;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
final class PaymentWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(
|
||||
string $provider,
|
||||
Request $request,
|
||||
PurchaseProvisioner $provisioner,
|
||||
LicenseDeliveryService $delivery
|
||||
): JsonResponse {
|
||||
$raw = (string) $request->getContent();
|
||||
|
||||
$verifier = match ($provider) {
|
||||
'paddle' => app(PaddleWebhookVerifier::class),
|
||||
'lemon-squeezy' => app(LemonSqueezyWebhookVerifier::class),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if (! $verifier) {
|
||||
return response()->json([
|
||||
'message' => 'Unknown webhook provider.',
|
||||
], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$verifier->verify($request, $raw);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Invalid webhook signature.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
try {
|
||||
$payload = json_decode(
|
||||
$raw,
|
||||
true,
|
||||
flags: JSON_THROW_ON_ERROR
|
||||
);
|
||||
} catch (Throwable) {
|
||||
return response()->json([
|
||||
'message' => 'Invalid JSON.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$externalID = (string) (
|
||||
$provider === 'paddle'
|
||||
? ($payload['event_id'] ?? '')
|
||||
: ($payload['meta']['event_name'] ?? '')
|
||||
.':'
|
||||
.($payload['data']['id'] ?? '')
|
||||
);
|
||||
|
||||
$eventType = (string) (
|
||||
$provider === 'paddle'
|
||||
? ($payload['event_type'] ?? 'unknown')
|
||||
: ($payload['meta']['event_name'] ?? 'unknown')
|
||||
);
|
||||
|
||||
if ($externalID === '') {
|
||||
return response()->json([
|
||||
'message' => 'Webhook event has no stable identifier.',
|
||||
], 400);
|
||||
}
|
||||
|
||||
$payloadHash = hash('sha256', $raw);
|
||||
|
||||
try {
|
||||
/*
|
||||
* Paddle can deliver several related events concurrently.
|
||||
*
|
||||
* Laravel retries this complete transaction when MySQL reports
|
||||
* a deadlock. Five attempts gives short-lived lock contention
|
||||
* enough opportunity to resolve without acknowledging an event
|
||||
* that was not actually processed.
|
||||
*/
|
||||
DB::transaction(
|
||||
function () use (
|
||||
$provider,
|
||||
$externalID,
|
||||
$eventType,
|
||||
$payload,
|
||||
$payloadHash,
|
||||
$provisioner
|
||||
): void {
|
||||
/*
|
||||
* Insert the event if this is its first delivery.
|
||||
*
|
||||
* Once present, lock the row so duplicate deliveries of
|
||||
* the same event cannot provision concurrently.
|
||||
*/
|
||||
LicenseWebhookEvent::firstOrCreate(
|
||||
[
|
||||
'provider' => $provider,
|
||||
'external_event_id' => $externalID,
|
||||
],
|
||||
[
|
||||
'event_type' => $eventType,
|
||||
'payload_sha256' => $payloadHash,
|
||||
'payload' => $payload,
|
||||
]
|
||||
);
|
||||
|
||||
$event = LicenseWebhookEvent::query()
|
||||
->where('provider', $provider)
|
||||
->where('external_event_id', $externalID)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
if ($event->processed_at) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! hash_equals(
|
||||
(string) $event->payload_sha256,
|
||||
$payloadHash
|
||||
)) {
|
||||
throw new RuntimeException(
|
||||
'Webhook ID was reused with a different payload.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$provisioner->provision(
|
||||
$provider,
|
||||
$payload
|
||||
);
|
||||
|
||||
$event->processed_at = now();
|
||||
$event->processing_error = null;
|
||||
$event->save();
|
||||
} catch (Throwable $e) {
|
||||
/*
|
||||
* This assignment is useful when the surrounding
|
||||
* transaction eventually succeeds. If processing
|
||||
* throws, the transaction itself is rolled back and
|
||||
* Paddle will retry the event.
|
||||
*/
|
||||
$event->processing_error = mb_substr(
|
||||
$e->getMessage(),
|
||||
0,
|
||||
4000
|
||||
);
|
||||
|
||||
$event->save();
|
||||
|
||||
throw $e;
|
||||
}
|
||||
},
|
||||
5
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
return response()->json([
|
||||
'message' => 'Webhook could not be processed.',
|
||||
], 500);
|
||||
}
|
||||
|
||||
/*
|
||||
* Delivery deliberately happens outside the webhook transaction.
|
||||
*
|
||||
* A temporary SMTP failure must not undo a successfully processed
|
||||
* payment event. The outbox retains anything that still needs
|
||||
* delivery.
|
||||
*/
|
||||
try {
|
||||
$delivery->deliverPending(10);
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'ok' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ final class AuthenticatedSessionController extends Controller
|
||||
public function store(Request $request)
|
||||
{
|
||||
$credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
|
||||
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) {
|
||||
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password'], 'disabled_at' => null], $request->boolean('remember'))) {
|
||||
return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email');
|
||||
}
|
||||
$request->session()->regenerate();
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use RuntimeException;
|
||||
|
||||
final class CheckoutController extends Controller
|
||||
{
|
||||
public function __invoke(): View
|
||||
{
|
||||
$paddle = config('sonardesk.paddle');
|
||||
$token = trim((string) ($paddle['client_side_token'] ?? ''));
|
||||
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('PADDLE_CLIENT_SIDE_TOKEN is not configured.');
|
||||
}
|
||||
|
||||
$plans = [
|
||||
'personal-monthly' => ['name' => 'Personal', 'billing' => 'Monthly', 'display_price' => '£4.99 / month', 'price_id' => $paddle['personal_monthly_price_id'], 'activation_limit' => 1],
|
||||
'personal-annual' => ['name' => 'Personal', 'billing' => 'Annual', 'display_price' => '£39.99 / year', 'price_id' => $paddle['personal_annual_price_id'], 'activation_limit' => 1],
|
||||
'professional-monthly' => ['name' => 'Professional', 'billing' => 'Monthly', 'display_price' => '£8.99 / month', 'price_id' => $paddle['professional_monthly_price_id'], 'activation_limit' => 3],
|
||||
'professional-annual' => ['name' => 'Professional', 'billing' => 'Annual', 'display_price' => '£79.99 / year', 'price_id' => $paddle['professional_annual_price_id'], 'activation_limit' => 3],
|
||||
];
|
||||
|
||||
foreach ($plans as $plan) {
|
||||
if (trim((string) $plan['price_id']) === '') {
|
||||
throw new RuntimeException('One or more Paddle price IDs are not configured.');
|
||||
}
|
||||
}
|
||||
|
||||
return view('checkout.buy', [
|
||||
'plans' => $plans,
|
||||
'paddleToken' => $token,
|
||||
'paddleEnvironment' => (string) ($paddle['environment'] ?? 'sandbox'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Portal;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseDeliveryOutbox;
|
||||
use App\Models\LicenseProduct;
|
||||
use App\Services\Payments\PaddleClient;
|
||||
use App\Services\Licensing\LicenseDeliveryService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
final class PortalController extends Controller
|
||||
{
|
||||
public function __construct(private PaddleClient $paddle, private LicenseDeliveryService $delivery) {}
|
||||
|
||||
private function owned(Request $request)
|
||||
{
|
||||
return License::with(['product', 'activations'])
|
||||
->whereRaw('lower(customer_email) = ?', [strtolower($request->user()->email)]);
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$licenses = $this->owned($request)->latest()->get();
|
||||
$hasProfessional = $licenses->contains(fn (License $license) => $license->is_usable && $license->edition === 'Professional');
|
||||
|
||||
return view('portal.index', compact('licenses', 'hasProfessional'));
|
||||
}
|
||||
|
||||
public function show(Request $request, License $license)
|
||||
{
|
||||
$this->authorise($request, $license);
|
||||
$license->load(['product', 'activations']);
|
||||
|
||||
return view('portal.show', compact('license'));
|
||||
}
|
||||
|
||||
public function upgrade(Request $request, License $license): RedirectResponse
|
||||
{
|
||||
$this->authorise($request, $license);
|
||||
abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
|
||||
abort_unless($license->edition === 'Personal' && $license->is_usable, 422);
|
||||
|
||||
$targetPriceID = $this->professionalPriceFor($license);
|
||||
$targetProduct = LicenseProduct::query()
|
||||
->where('payment_provider', 'paddle')
|
||||
->where('external_price_id', $targetPriceID)
|
||||
->where('active', true)
|
||||
->firstOrFail();
|
||||
|
||||
try {
|
||||
$subscription = $this->paddle->updateSubscriptionPrice($license->external_subscription_id, $targetPriceID);
|
||||
} catch (RuntimeException $exception) {
|
||||
return back()->withErrors(['licence' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
// Apply the entitlement immediately. Paddle webhooks remain authoritative and
|
||||
// will reconcile subscription status/current-period details afterwards.
|
||||
$license->update([
|
||||
'license_product_id' => $targetProduct->id,
|
||||
'edition' => $targetProduct->edition,
|
||||
'activation_limit' => $targetProduct->activation_limit,
|
||||
'max_major_version' => $targetProduct->max_major_version,
|
||||
'external_price_id' => $targetPriceID,
|
||||
'subscription_status' => (string) ($subscription['status'] ?? $license->subscription_status),
|
||||
'revoked_at' => null,
|
||||
'revoke_reason' => null,
|
||||
]);
|
||||
|
||||
return redirect()->route('portal.show', $license)->with('status', 'Upgraded to Professional. Your existing Paddle subscription was changed; no second subscription was created.');
|
||||
}
|
||||
|
||||
public function cancel(Request $request, License $license): RedirectResponse
|
||||
{
|
||||
$this->authorise($request, $license);
|
||||
abort_unless($license->payment_provider === 'paddle' && $license->external_subscription_id, 422);
|
||||
abort_unless(! in_array($license->subscription_status, ['canceled'], true), 422);
|
||||
|
||||
try {
|
||||
$this->paddle->cancelSubscriptionImmediately($license->external_subscription_id);
|
||||
} catch (RuntimeException $exception) {
|
||||
return back()->withErrors(['licence' => $exception->getMessage()]);
|
||||
}
|
||||
|
||||
$license->update([
|
||||
'subscription_status' => 'canceled',
|
||||
'expires_at' => now(),
|
||||
'revoked_at' => now(),
|
||||
'revoke_reason' => 'Subscription canceled from licence portal',
|
||||
'cancel_scheduled_at' => null,
|
||||
]);
|
||||
|
||||
return redirect()->route('portal.index')->with('status', 'Subscription canceled in Paddle and the licence is no longer active.');
|
||||
}
|
||||
|
||||
public function deactivate(Request $request, License $license, int $activation)
|
||||
{
|
||||
$this->authorise($request, $license);
|
||||
$item = $license->activations()->whereKey($activation)->firstOrFail();
|
||||
if (! $item->deactivated_at) {
|
||||
$item->update(['deactivated_at' => now()]);
|
||||
}
|
||||
|
||||
return back()->with('status', 'Mac deactivated. SonarDesk will detect this through the licensing API.');
|
||||
}
|
||||
|
||||
public function resend(Request $request, License $license)
|
||||
{
|
||||
$this->authorise($request, $license);
|
||||
$out = LicenseDeliveryOutbox::where('license_id', $license->id)->first();
|
||||
if (! $out) {
|
||||
return back()->withErrors(['licence' => 'This licence was issued manually and its full key is not recoverable. Contact support.']);
|
||||
}
|
||||
|
||||
$out->update([
|
||||
'recipient_email' => strtolower($request->user()->email),
|
||||
'delivered_at' => null,
|
||||
]);
|
||||
$this->delivery->deliverPending(1);
|
||||
|
||||
return back()->with('status', 'Licence key sent to your verified email address.');
|
||||
}
|
||||
|
||||
private function authorise(Request $request, License $license): void
|
||||
{
|
||||
abort_unless(strtolower((string) $license->customer_email) === strtolower($request->user()->email), 404);
|
||||
}
|
||||
|
||||
private function professionalPriceFor(License $license): string
|
||||
{
|
||||
$paddle = config('sonardesk.paddle');
|
||||
$current = (string) $license->external_price_id;
|
||||
|
||||
if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) {
|
||||
return (string) ($paddle['professional_annual_price_id'] ?? '');
|
||||
}
|
||||
|
||||
return (string) ($paddle['professional_monthly_price_id'] ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CmsPage;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
final class SeoController extends Controller
|
||||
{
|
||||
public function sitemap(): Response
|
||||
{
|
||||
$static = collect([
|
||||
['route' => 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
|
||||
['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
|
||||
['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
|
||||
['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
|
||||
['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
|
||||
['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
])->filter(fn (array $item) => Route::has($item['route']))->values();
|
||||
|
||||
$pages = CmsPage::query()
|
||||
->where('is_published', true)
|
||||
->where('include_in_sitemap', true)
|
||||
->where('robots_index', true)
|
||||
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
|
||||
->orderBy('slug')
|
||||
->get();
|
||||
|
||||
return response()
|
||||
->view('seo.sitemap', compact('static', 'pages'))
|
||||
->header('Content-Type', 'application/xml; charset=UTF-8')
|
||||
->header('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
|
||||
public function robots(): Response
|
||||
{
|
||||
$indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
|
||||
|
||||
$body = $indexable
|
||||
? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
|
||||
: "User-agent: *\nDisallow: /\n";
|
||||
|
||||
return response($body, 200, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user