This commit is contained in:
Mark Gregory
2026-10-19 14:11:28 +01:00
parent 26a2b448a5
commit ac0c5410c1
128 changed files with 5593 additions and 217 deletions
+134 -23
View File
@@ -1,4 +1,5 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
@@ -17,37 +18,147 @@ 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'],
'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);
return DB::transaction(function () use ($data, $signer) {
$license = License::where('license_key_hash', LicenseKey::hash($data['licenseKey']))
->lockForUpdate()
->first();
$activation = LicenseActivation::where('license_id',$license->id)->where('installation_id',strtolower($data['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'=>strtolower($data['installationID']),'platform'=>$data['platform'],'app_version'=>$data['appVersion'],'activated_at'=>now(),'last_seen_at'=>now()]);
if (! $license) {
throw ValidationException::withMessages(['licenseKey' => 'The licence key is not valid.']);
}
return response()->json(['licenseToken'=>$signer->sign($license, $data['installationID'])]);
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)[]);
$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) []);
}
}
+58 -17
View File
@@ -1,8 +1,10 @@
<?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;
@@ -12,26 +14,65 @@ use Illuminate\Support\Facades\DB;
final class PaymentWebhookController extends Controller
{
public function __invoke(string $provider, Request $request, PurchaseProvisioner $provisioner): JsonResponse
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);
$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);
}
try {
DB::transaction(function() use($provider,$externalID,$eventType,$payload,$raw,$provisioner){
$event=LicenseWebhookEvent::firstOrCreate(['provider'=>$provider,'external_event_id'=>$externalID],['event_type'=>$eventType,'payload_sha256'=>hash('sha256',$raw),'payload'=>$payload]);
if ($event->processed_at) return;
if (!hash_equals($event->payload_sha256,hash('sha256',$raw))) throw new \RuntimeException('Webhook ID was reused with a different payload.');
try { $provisioner->provision($provider,$payload); $event->update(['processed_at'=>now(),'processing_error'=>null]); }
catch (\Throwable $e) { $event->update(['processing_error'=>mb_substr($e->getMessage(),0,4000)]); throw $e; }
DB::transaction(function () use ($provider, $externalID, $eventType, $payload, $raw, $provisioner) {
$event = LicenseWebhookEvent::firstOrCreate(['provider' => $provider, 'external_event_id' => $externalID], ['event_type' => $eventType, 'payload_sha256' => hash('sha256', $raw), 'payload' => $payload]);
if ($event->processed_at) {
return;
}
if (! hash_equals($event->payload_sha256, hash('sha256', $raw))) {
throw new \RuntimeException('Webhook ID was reused with a different payload.');
}
try {
$provisioner->provision($provider, $payload);
$event->update(['processed_at' => now(), 'processing_error' => null]);
} catch (\Throwable $e) {
$event->update(['processing_error' => mb_substr($e->getMessage(), 0, 4000)]);
throw $e;
}
});
} catch (\Throwable $e) { report($e); return response()->json(['message'=>'Webhook could not be processed.'],500); }
return response()->json(['ok'=>true]);
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Webhook could not be processed.'], 500);
}
// Delivery is intentionally outside the webhook transaction. A temporary mail
// outage must not roll back an otherwise valid Paddle event. Pending outbox
// rows remain available for portal resend or a later webhook delivery attempt.
try {
$delivery->deliverPending(10);
} catch (\Throwable $e) {
report($e);
}
return response()->json(['ok' => true]);
}
}