move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 14:50:13 +01:00
parent 5f0d2ceb72
commit 38b4a7406f
879 changed files with 24021 additions and 1 deletions
View File
+8
View File
@@ -0,0 +1,8 @@
<?php
return [
'name' => 'Licensing',
'features' =>
[
],
];
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Licensing\Console;
use Modules\Licensing\Services\LicenseDeliveryService;
use Illuminate\Console\Command;
final class SonarDeskLicenseDeliver extends Command
{
protected $signature = 'sonardesk:license-deliver {--limit=50 : Maximum pending licence emails to send}';
protected $description = 'Deliver pending SonarDesk licence-key emails';
public function handle(LicenseDeliveryService $delivery): int
{
$limit = max(1, min((int) $this->option('limit'), 100));
$count = $delivery->deliverPending($limit);
$this->info("Delivered {$count} licence email(s).");
return self::SUCCESS;
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace Modules\Licensing\Console;
use Modules\Licensing\Models\LicenseProduct;
use Modules\Licensing\Services\LicenseIssuer;
use Illuminate\Console\Command;
class SonarDeskLicenseIssue extends Command
{
protected $signature = 'sonardesk:license:issue {product} {--name=} {--email=}';
protected $description = 'Issue a manual SonarDesk licence key';
public function handle(LicenseIssuer $issuer): int
{
$p = LicenseProduct::where('code', $this->argument('product'))->firstOrFail();
[$l,$key] = $issuer->issue($p, $this->option('name') ?: null, $this->option('email') ?: null);
if (! $key) {
$this->warn('Licence already existed; plaintext key is intentionally not recoverable.');
return self::FAILURE;
} $this->line('Licence ID: '.$l->id);
$this->line('Licence key: '.$key);
$this->warn('This is the only time the plaintext key is shown.');
return self::SUCCESS;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace Modules\Licensing\Console;
use Modules\Licensing\Models\License;
use Illuminate\Console\Command;
class SonarDeskLicenseRevoke extends Command
{
protected $signature = 'sonardesk:license:revoke {id} {--reason=Administrative revocation}';
protected $description = 'Revoke a SonarDesk licence';
public function handle(): int
{
$l = License::findOrFail($this->argument('id'));
$l->update(['revoked_at' => now(), 'revoke_reason' => $this->option('reason')]);
$this->info('Licence revoked.');
return self::SUCCESS;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace Modules\Licensing\Console;
use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Console\Command;
class SonarDeskProductCreate extends Command
{
protected $signature = 'sonardesk:product {code} {--name=SonarDesk} {--edition=Professional} {--activations=1} {--max-major=1} {--provider=} {--product-id=} {--price-id=}';
protected $description = 'Create or update a SonarDesk licensing product';
public function handle(): int
{
$p = LicenseProduct::updateOrCreate(['code' => $this->argument('code')], ['name' => $this->option('name'), 'edition' => $this->option('edition'), 'activation_limit' => (int) $this->option('activations'), 'max_major_version' => $this->option('max-major') !== '' ? (int) $this->option('max-major') : null, 'payment_provider' => $this->option('provider') ?: null, 'external_product_id' => $this->option('product-id') ?: null, 'external_price_id' => $this->option('price-id') ?: null, 'active' => true]);
$this->info("Product {$p->code} ready.");
return self::SUCCESS;
}
}
@@ -0,0 +1,92 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('license_products', function (Blueprint $table) {
$table->id();
$table->string('code')->unique();
$table->string('name');
$table->string('edition')->default('Professional');
$table->unsignedInteger('activation_limit')->default(1);
$table->unsignedInteger('max_major_version')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_product_id')->nullable();
$table->string('external_price_id')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
$table->unique(['payment_provider', 'external_price_id']);
});
Schema::create('licenses', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('license_product_id')->constrained('license_products');
$table->string('license_key_hash', 64)->unique();
$table->string('license_key_last4', 4);
$table->string('customer_name')->nullable();
$table->string('customer_email')->nullable()->index();
$table->string('edition');
$table->unsignedInteger('activation_limit');
$table->unsignedInteger('max_major_version')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->string('revoke_reason')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_order_id')->nullable();
$table->string('external_customer_id')->nullable();
$table->timestamps();
$table->unique(['payment_provider', 'external_order_id']);
});
Schema::create('license_delivery_outbox', function (Blueprint $table) {
$table->id();
$table->uuid('license_id')->unique();
$table->text('encrypted_license_key');
$table->string('recipient_email')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
});
Schema::create('license_activations', function (Blueprint $table) {
$table->id();
$table->uuid('license_id');
$table->uuid('installation_id');
$table->string('platform', 32)->nullable();
$table->string('app_version', 32)->nullable();
$table->timestamp('activated_at');
$table->timestamp('last_seen_at')->nullable();
$table->timestamp('deactivated_at')->nullable();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
$table->unique(['license_id', 'installation_id']);
$table->index(['license_id', 'deactivated_at']);
});
Schema::create('license_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider');
$table->string('external_event_id');
$table->string('event_type');
$table->string('payload_sha256', 64);
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->text('processing_error')->nullable();
$table->timestamps();
$table->unique(['provider', 'external_event_id']);
});
}
public function down(): void
{
Schema::dropIfExists('license_webhook_events');
Schema::dropIfExists('license_activations');
Schema::dropIfExists('license_delivery_outbox');
Schema::dropIfExists('licenses');
Schema::dropIfExists('license_products');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->boolean('is_admin')->default(false)->index());
}
if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id'));
}
}
public function down(): void
{
if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name'));
}
if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin'));
}
}
};
@@ -0,0 +1,21 @@
<?php
namespace Modules\Licensing\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;
class LicensingDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Model::unguard();
// $this->call("OthersTableSeeder");
}
}
View File
+12
View File
@@ -0,0 +1,12 @@
<?php
use Modules\Licensing\Support\ModuleInfo;
if (! function_exists('licensing_feature_enabled')) {
/**
* Check if a feature is enabled in the config.
*/
function licensing_feature_enabled(string $key): bool
{
return (bool) config(ModuleInfo::nameLower().".features.{$key}", false);
}
}
@@ -0,0 +1,56 @@
<?php
namespace Modules\Licensing\Http\Controllers\Admin;
use Illuminate\Contracts\View\View;
use Illuminate\Routing\Controller;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseProduct;
final class LicensingDashboardController extends Controller
{
public function index(): View
{
return view('licensing::admin.index', [
'licenseCount' => License::query()->count(),
'activeCount' => License::query()->whereNull('revoked_at')->where(function ($q): void {
$q->whereNull('expires_at')->orWhere('expires_at', '>', now());
})->count(),
'productCount' => LicenseProduct::query()->count(),
'recentLicenses' => License::query()->with('product')->latest()->limit(10)->get(),
]);
}
public function licenses(): View
{
return view('licensing::admin.licenses', [
'licenses' => License::query()->with('product')->latest()->paginate(25),
]);
}
public function show(License $license): View
{
return view('licensing::admin.show', [
'license' => $license->load(['product', 'activations']),
]);
}
/** Read-only snapshot of existing licensing subscription metadata. */
public function subscriptions(): View
{
$subscriptions = License::query()
->whereNotNull('external_subscription_id')
->with('product')
->orderByDesc('created_at')
->paginate(25);
$total = License::query()->whereNotNull('external_subscription_id')->count();
$active = License::query()->whereNotNull('external_subscription_id')
->whereIn('subscription_status', ['active', 'trialing'])->count();
$cancelScheduled = License::query()->whereNotNull('external_subscription_id')
->whereNotNull('cancel_scheduled_at')->count();
return view('licensing::admin.subscriptions', compact('subscriptions', 'total', 'active', 'cancelScheduled'));
}
}
@@ -0,0 +1,164 @@
<?php
namespace Modules\Licensing\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseActivation;
use Modules\Licensing\Services\LicenseKey;
use Modules\Licensing\Services\LicenseSigner;
use Modules\Licensing\Services\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) []);
}
}
@@ -0,0 +1,191 @@
<?php
namespace Modules\Licensing\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Modules\Licensing\Models\LicenseWebhookEvent;
use Modules\Licensing\Services\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use Modules\Paddle\Services\PaddleWebhookVerifier;
use Modules\Paddle\Services\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,
]);
}
}
@@ -0,0 +1,79 @@
<?php
namespace Modules\Licensing\Http\Controllers;
use Illuminate\Contracts\Support\Renderable;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
class LicensingController extends Controller
{
/**
* Display a listing of the resource.
* @return Renderable
*/
public function index()
{
return view('licensing::index');
}
/**
* Show the form for creating a new resource.
* @return Renderable
*/
public function create()
{
return view('licensing::create');
}
/**
* Store a newly created resource in storage.
* @param Request $request
* @return Renderable
*/
public function store(Request $request)
{
//
}
/**
* Show the specified resource.
* @param int $id
* @return Renderable
*/
public function show($id)
{
return view('licensing::show');
}
/**
* Show the form for editing the specified resource.
* @param int $id
* @return Renderable
*/
public function edit($id)
{
return view('licensing::edit');
}
/**
* Update the specified resource in storage.
* @param Request $request
* @param int $id
* @return Renderable
*/
public function update(Request $request, $id)
{
//
}
/**
* Remove the specified resource from storage.
* @param int $id
* @return Renderable
*/
public function destroy($id)
{
//
}
}
@@ -0,0 +1,143 @@
<?php
namespace Modules\Licensing\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseProduct;
use Modules\Paddle\Services\PaddleClient;
use Modules\Licensing\Services\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('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'] ?? '');
}
}
View File
+36
View File
@@ -0,0 +1,36 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class License extends Model
{
use HasUuids;
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = ['license_product_id', 'license_key_hash', 'license_key_last4', 'customer_name', 'customer_email', 'edition', 'activation_limit', 'max_major_version', 'expires_at', 'revoked_at', 'revoke_reason', 'payment_provider', 'external_order_id', 'external_customer_id', 'external_subscription_id', 'external_price_id', 'subscription_status', 'current_period_ends_at', 'cancel_scheduled_at'];
protected $casts = ['expires_at' => 'datetime', 'revoked_at' => 'datetime', 'activation_limit' => 'integer', 'max_major_version' => 'integer', 'current_period_ends_at' => 'datetime', 'cancel_scheduled_at' => 'datetime'];
public function product(): BelongsTo
{
return $this->belongsTo(LicenseProduct::class, 'license_product_id');
}
public function activations(): HasMany
{
return $this->hasMany(LicenseActivation::class);
}
public function getIsUsableAttribute(): bool
{
return $this->revoked_at === null && ($this->expires_at === null || $this->expires_at->isFuture());
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseActivation extends Model
{
public $timestamps = false;
protected $fillable = ['license_id', 'installation_id', 'installation_name', 'platform', 'app_version', 'activated_at', 'last_seen_at', 'deactivated_at'];
protected $casts = ['activated_at' => 'datetime', 'last_seen_at' => 'datetime', 'deactivated_at' => 'datetime'];
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class LicenseDeliveryOutbox extends Model
{
protected $table = 'license_delivery_outbox';
protected $fillable = ['license_id', 'encrypted_license_key', 'recipient_email', 'delivered_at'];
protected $casts = ['delivered_at' => 'datetime'];
public function license(): BelongsTo
{
return $this->belongsTo(License::class);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseProduct extends Model
{
protected $fillable = ['code', 'name', 'edition', 'activation_limit', 'max_major_version', 'payment_provider', 'external_product_id', 'external_price_id', 'active'];
protected $casts = ['active' => 'boolean', 'activation_limit' => 'integer', 'max_major_version' => 'integer'];
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
class LicenseWebhookEvent extends Model
{
protected $fillable = ['provider', 'external_event_id', 'event_type', 'payload_sha256', 'payload', 'processed_at', 'processing_error'];
protected $casts = ['payload' => 'array', 'processed_at' => 'datetime'];
}
@@ -0,0 +1,35 @@
<?php
namespace Modules\Licensing\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use App\Models\User;
/** Explicitly mounted licensing API. Portal integration remains gated pending auth review. */
final class IntegratedLicensingServiceProvider extends ServiceProvider
{
public function boot(): void
{
RateLimiter::for('license-activation', static fn (Request $request): array => [
Limit::perMinute(12)->by($request->ip()),
Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
]);
$this->loadMigrationsFrom(__DIR__.'/../Database/Migrations');
$this->loadViewsFrom(__DIR__.'/../Resources/views', 'licensing');
$this->mergeConfigFrom(__DIR__.'/../Config/config.php', 'licensing');
// Explicit role-based authorization; deny access by default.
Gate::define('manage-sonardesk-licensing', static fn (User $user): bool =>
($user->hasPermissionTo('manage-sonardesk-licensing', 'web') || $user->hasAnyRole(['Super Admin', 'super-admin', 'Administrator']))
);
Route::prefix('api')->middleware('api')->group(__DIR__.'/../Routes/api.php');
Route::group([], __DIR__.'/../Routes/admin.php');
}
}
@@ -0,0 +1,171 @@
<?php
namespace Modules\Licensing\Providers;
use Illuminate\Support\ServiceProvider;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Database\Eloquent\Factory;
use Illuminate\Support\Facades\File;
use Modules\Licensing\Support\ModuleInfo;
use Config;
class LicensingServiceProvider extends ServiceProvider
{
/**
* @var string $moduleName
*/
protected string $moduleName = 'Licensing';
/**
* @var string $moduleNameLower
*/
protected string $moduleNameLower = 'licensing';
/**
* Get the services provided by the provider.
*
* @return array
*/
public function provides():array
{
return [];
}
/**
* Boot the application events.
*
* @return void
*/
public function boot() : void
{
RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)
->by(strtolower((string) $request->input('email')).'|'.$request->ip()));
RateLimiter::for('registration', fn (Request $request) =>
Limit::perHour(5)->by($request->ip())
);
RateLimiter::for('license-activation', function (Request $request): array {
return [
Limit::perMinute(12)->by($request->ip()),
Limit::perMinute(6)->by(hash('sha256', (string) $request->input('installationID'))),
];
});
$this->registerTranslations();
$this->registerConfig();
$this->registerViews();
$this->registerHelpers();
$this->registerCommands();
$this->loadMigrationsFrom(module_path(ModuleInfo::name(), 'Database/Migrations'));
}
/**
* Register the service provider.
*
* @return void
*/
public function register(): void
{
$this->app->register(RouteServiceProvider::class);
}
/**
* Register the helpers.
*
* @return void
*/
protected function registerHelpers(): void
{
foreach (glob(__DIR__.'/../Helpers/*.php') as $filename) {
require_once $filename;
}
}
/**
* Dynamically Register Commands.
*
* @return void
*/
public function registerCommands(): void
{
if (!$this->app->runningInConsole()) {
return;
}
$commandPath = __DIR__ . '/../Console';
$commands = collect(File::allFiles($commandPath))
->map(function ($file) {
$class = 'Modules\\' . $this->moduleName . '\\Console\\' . $file->getFilenameWithoutExtension();
return $class;
})
->filter(function ($class) {
return class_exists($class);
})
->values()
->all();
$this->commands($commands);
}
/**
* Register config.
*
* @return void
*/
protected function registerConfig(): void
{
$this->publishes([
module_path(ModuleInfo::name(), 'Config/config.php') => config_path( ModuleInfo::nameLower(). '.php'),
], 'config');
$this->mergeConfigFrom(
module_path(ModuleInfo::name(), 'Config/config.php'), ModuleInfo::nameLower()
);
}
/**
* Register views.
*
* @return void
*/
public function registerViews(): void
{
$viewPath = resource_path('views/modules/' . ModuleInfo::nameLower());
$sourcePath = module_path(ModuleInfo::name(), 'Resources/views');
$this->publishes([
$sourcePath => $viewPath
], ['views', ModuleInfo::nameLower() . '-module-views']);
$this->loadViewsFrom(array_merge($this->getPublishableViewPaths(), [$sourcePath]), ModuleInfo::nameLower());
}
/**
* Register translations.
*
* @return void
*/
public function registerTranslations(): void
{
$langPath = resource_path('lang/modules/' . ModuleInfo::nameLower());
if (is_dir($langPath)) {
$this->loadTranslationsFrom($langPath, ModuleInfo::nameLower());
} else {
$this->loadTranslationsFrom(module_path(ModuleInfo::name(), 'Resources/lang'), ModuleInfo::nameLower());
}
}
/**
* Regsiter View Paths
* @return array
*/
private function getPublishableViewPaths(): array
{
$paths = [];
foreach (Config::get('view.paths') as $path) {
if (is_dir($path . '/modules/' . ModuleInfo::nameLower())) {
$paths[] = $path . '/modules/' . ModuleInfo::nameLower();
}
}
return $paths;
}
}
@@ -0,0 +1,69 @@
<?php
namespace Modules\Licensing\Providers;
use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
class RouteServiceProvider extends ServiceProvider
{
/**
* The module namespace to assume when generating URLs to actions.
*
* @var string
*/
protected $moduleNamespace = 'Modules\Licensing\Http\Controllers';
/**
* Called before routes are registered.
*
* Register any model bindings or pattern based filters.
*
* @return void
*/
public function boot()
{
parent::boot();
}
/**
* Define the routes for the application.
*
* @return void
*/
public function map()
{
$this->mapApiRoutes();
$this->mapWebRoutes();
}
/**
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
{
Route::middleware('web')
->namespace($this->moduleNamespace)
->group(module_path('Licensing', '/Routes/web.php'));
}
/**
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*
* @return void
*/
protected function mapApiRoutes()
{
Route::prefix('api')
->middleware('api')
->namespace($this->moduleNamespace)
->group(module_path('Licensing', '/Routes/api.php'));
}
}
@@ -0,0 +1,3 @@
@extends('licensing::admin.layout')
@section('title', 'Licensing Overview')
@section('content')<h1>SonarDesk licensing</h1><div class="cards"><div class="card"><div class="muted">Total licences</div><div class="metric">{{ number_format($licenseCount) }}</div></div><div class="card"><div class="muted">Usable licences</div><div class="metric">{{ number_format($activeCount) }}</div></div><div class="card"><div class="muted">Products</div><div class="metric">{{ number_format($productCount) }}</div></div></div><h2>Recent licences</h2><div class="table-wrap"><table><thead><tr><th>Customer</th><th>Product</th><th>Edition</th><th>Status</th><th>Created</th></tr></thead><tbody>@forelse($recentLicenses as $license)<tr><td><a href="{{ route('administration.sonardesk.licenses.show', $license) }}">{{ $license->customer_email ?: 'Unknown customer' }}</a></td><td>{{ $license->product?->name ?? '—' }}</td><td>{{ $license->edition }}</td><td>{{ $license->is_usable ? 'Usable' : 'Inactive' }}</td><td>{{ $license->created_at?->format('d M Y') }}</td></tr>@empty<tr><td colspan="5">No licences found.</td></tr>@endforelse</tbody></table></div><p><a href="{{ route('administration.sonardesk.licenses') }}">View all licences →</a></p>@endsection
@@ -0,0 +1,37 @@
{{-- Shared ABSDev administration shell; no separate SonarDesk layout or navigation. --}}
<x-layouts.app :title="trim($__env->yieldContent('title', 'SonarDesk')) . ' · SonarDesk'">
<div class="space-y-6">
<div class="flex flex-wrap items-center justify-between gap-4 border-b border-zinc-200 pb-5 dark:border-zinc-700">
<div>
<p class="text-xs font-semibold uppercase tracking-widest text-amber-600 dark:text-amber-400">ABSDev / Products</p>
<h1 class="mt-1 text-3xl font-bold tracking-tight text-zinc-900 dark:text-white">SonarDesk</h1>
<p class="mt-1 text-sm text-zinc-500 dark:text-zinc-400">Licensing administration · Read only</p>
</div>
<div class="flex flex-wrap gap-2 text-sm">
<a class="rounded-lg border border-zinc-300 px-4 py-2 hover:bg-zinc-100 dark:border-zinc-600 dark:hover:bg-zinc-800" href="{{ route('administration.sonardesk.index') }}">Overview</a>
<a class="rounded-lg border border-zinc-300 px-4 py-2 hover:bg-zinc-100 dark:border-zinc-600 dark:hover:bg-zinc-800" href="{{ route('administration.sonardesk.licenses') }}">Licences</a>
</div>
</div>
<div class="sonardesk-admin space-y-6">@yield('content')</div>
</div>
<style>
.sonardesk-admin h1{font-size:1.65rem;font-weight:700;margin-bottom:1.25rem}
.sonardesk-admin h2{font-size:1.125rem;font-weight:650;margin:1.5rem 0 .85rem}
.sonardesk-admin a{color:#b7791f;text-decoration:underline;text-underline-offset:3px}
.sonardesk-admin .cards,.sonardesk-admin .detail{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,240px),1fr));gap:1rem;margin-bottom:1.5rem}
.sonardesk-admin .card{border:1px solid #d4d4d8;border-radius:.85rem;padding:1.4rem;background:rgba(127,127,127,.035)}
.dark .sonardesk-admin .card{border-color:#52525b}
.sonardesk-admin .metric{font-size:2rem;font-weight:750;color:#b7791f}
.sonardesk-admin .muted,.sonardesk-admin dt{color:#71717a}
.sonardesk-admin dd{margin:.25rem 0 .8rem;overflow-wrap:anywhere}
.sonardesk-admin .table-wrap{width:100%;overflow-x:auto;border:1px solid #d4d4d8;border-radius:.85rem}
.dark .sonardesk-admin .table-wrap{border-color:#52525b}
.sonardesk-admin table{width:100%;border-collapse:collapse;text-align:left;font-size:.875rem}
.sonardesk-admin th,.sonardesk-admin td{padding:.85rem 1rem;border-bottom:1px solid #d4d4d8;vertical-align:top}
.dark .sonardesk-admin th,.dark .sonardesk-admin td{border-color:#52525b}
.sonardesk-admin th{font-size:.75rem;letter-spacing:.04em;text-transform:uppercase}
.sonardesk-admin .pill{border-radius:999px;padding:.25rem .65rem;background:#e4e4e7}
.dark .sonardesk-admin .pill{background:#3f3f46}
.sonardesk-admin .pager{margin-top:1.25rem}
</style>
</x-layouts.app>
@@ -0,0 +1,3 @@
@extends('licensing::admin.layout')
@section('title', 'Licences')
@section('content')<h1>Licences</h1><div class="table-wrap"><table><thead><tr><th>Customer</th><th>Product</th><th>Edition</th><th>Provider</th><th>Status</th><th>Created</th></tr></thead><tbody>@forelse($licenses as $license)<tr><td><a href="{{ route('administration.sonardesk.licenses.show', $license) }}">{{ $license->customer_email ?: 'Unknown customer' }}</a></td><td>{{ $license->product?->name ?? '—' }}</td><td>{{ $license->edition }}</td><td>{{ $license->payment_provider ?? '—' }}</td><td>{{ $license->is_usable ? 'Usable' : 'Inactive' }}</td><td>{{ $license->created_at?->format('d M Y') }}</td></tr>@empty<tr><td colspan="6">No licences found.</td></tr>@endforelse</tbody></table></div><div class="pager">{{ $licenses->links() }}</div>@endsection
@@ -0,0 +1,3 @@
@extends('licensing::admin.layout')
@section('title', 'Licence details')
@section('content')<h1>Licence details</h1><div class="detail"><section class="card"><h2>Customer and licence</h2><dl><dt>Customer</dt><dd>{{ $license->customer_name ?? '—' }}</dd><dt>Email</dt><dd>{{ $license->customer_email ?? '—' }}</dd><dt>Product</dt><dd>{{ $license->product?->name ?? '—' }}</dd><dt>Edition</dt><dd>{{ $license->edition }}</dd><dt>Key (last four only)</dt><dd>•••• {{ $license->license_key_last4 }}</dd><dt>Status</dt><dd>{{ $license->is_usable ? 'Usable' : 'Inactive' }}</dd></dl></section><section class="card"><h2>Subscription</h2><dl><dt>Provider</dt><dd>{{ $license->payment_provider ?? '—' }}</dd><dt>Subscription status</dt><dd>{{ $license->subscription_status ?? '—' }}</dd><dt>Period ends</dt><dd>{{ $license->current_period_ends_at?->format('d M Y') ?? '—' }}</dd><dt>Expires</dt><dd>{{ $license->expires_at?->format('d M Y') ?? '—' }}</dd><dt>Activation limit</dt><dd>{{ $license->activation_limit }}</dd></dl></section></div><h2>Device activations</h2><div class="table-wrap"><table><thead><tr><th>Platform</th><th>Version</th><th>Activated</th><th>Last seen</th><th>Status</th></tr></thead><tbody>@forelse($license->activations as $activation)<tr><td>{{ $activation->platform ?? '—' }}</td><td>{{ $activation->app_version ?? '—' }}</td><td>{{ $activation->activated_at?->format('d M Y H:i') ?? '—' }}</td><td>{{ $activation->last_seen_at?->format('d M Y H:i') ?? '—' }}</td><td>{{ $activation->deactivated_at ? 'Deactivated' : 'Active' }}</td></tr>@empty<tr><td colspan="5">No device activations.</td></tr>@endforelse</tbody></table></div>@endsection
@@ -0,0 +1,25 @@
@extends('licensing::admin.layout')
@section('title', 'SonarDesk Subscriptions')
@section('content')
<h1>Subscriptions</h1>
<p class="muted">Read-only subscription records stored in ABSDev. This is not a live Paddle reconciliation.</p>
<div class="cards">
<div class="card"><div class="muted">Subscription-linked licences</div><div class="metric">{{ number_format($total) }}</div></div>
<div class="card"><div class="muted">Active / trialing</div><div class="metric">{{ number_format($active) }}</div></div>
<div class="card"><div class="muted">Cancellation scheduled</div><div class="metric">{{ number_format($cancelScheduled) }}</div></div>
</div>
<div class="table-wrap"><table><thead><tr><th>Customer</th><th>Product</th><th>Provider</th><th>Status</th><th>Period ends</th><th>Cancellation</th><th>Licence</th></tr></thead><tbody>
@forelse($subscriptions as $subscription)
<tr>
<td>{{ $subscription->customer_email ?: '—' }}</td>
<td>{{ $subscription->product?->name ?? '—' }}</td>
<td>{{ $subscription->payment_provider ?: '—' }}</td>
<td><span class="pill">{{ $subscription->subscription_status ?: 'Unknown' }}</span></td>
<td>{{ $subscription->current_period_ends_at?->format('d M Y') ?? '—' }}</td>
<td>{{ $subscription->cancel_scheduled_at?->format('d M Y') ?? '—' }}</td>
<td><a href="{{ route('administration.sonardesk.licenses.show', $subscription) }}">View licence</a></td>
</tr>
@empty<tr><td colspan="7">No subscription-linked licences in the local database.</td></tr>@endforelse
</tbody></table></div>
<div class="pager">{{ $subscriptions->links() }}</div>
@endsection
@@ -0,0 +1,9 @@
@extends('licensing::layouts.master')
@section('content')
<h1>Hello World</h1>
<p>
This view is loaded from module: {!! config('licensing.name') !!}
</p>
@endsection
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Module Licensing</title>
{{-- Laravel Mix - CSS File --}}
{{-- <link rel="stylesheet" href="{{ mix('css/licensing.css') }}"> --}}
</head>
<body>
@yield('content')
{{-- Laravel Mix - JS File --}}
{{-- <script src="{{ mix('js/licensing.js') }}"></script> --}}
</body>
</html>
View File
+15
View File
@@ -0,0 +1,15 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\Admin\LicensingDashboardController;
// Deliberately read-only until the identity and payment migration is verified.
Route::middleware(['web', 'auth', 'verified', 'can:manage-sonardesk-licensing'])
->prefix('administration/sonardesk')
->name('administration.sonardesk.')
->group(function (): void {
Route::get('/', [LicensingDashboardController::class, 'index'])->name('index');
Route::get('/licenses', [LicensingDashboardController::class, 'licenses'])->name('licenses');
Route::get('/subscriptions', [LicensingDashboardController::class, 'subscriptions'])->name('subscriptions');
Route::get('/licenses/{license}', [LicensingDashboardController::class, 'show'])->name('licenses.show');
});
+12
View File
@@ -0,0 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\Api\LicenseActivationController;
use Modules\Licensing\Http\Controllers\Api\PaymentWebhookController;
Route::prefix('v1')->group(function (): void {
Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation');
Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation');
Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation');
Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']);
});
+13
View File
@@ -0,0 +1,13 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Licensing\Http\Controllers\Portal\PortalController;
Route::middleware(['auth', 'verified'])->prefix('portal')->name('portal.')->group(function (): void {
Route::get('/', [PortalController::class, 'index'])->name('index');
Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show');
Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade');
Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel');
Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend');
Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate');
});
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace Modules\Licensing\Services;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
final class LicenseDeliveryService
{
public function deliverPending(int $limit = 20): int
{
$delivered = 0;
$pending = LicenseDeliveryOutbox::query()
->with('license')
->whereNull('delivered_at')
->whereNotNull('recipient_email')
->where('recipient_email', '!=', '')
->oldest()
->limit(max(1, min($limit, 100)))
->get();
foreach ($pending as $outbox) {
$license = $outbox->license;
if (! $license) {
continue;
}
$plain = Crypt::decryptString($outbox->encrypted_license_key);
$recipient = strtolower(trim((string) $outbox->recipient_email));
if ($recipient === '') {
continue;
}
$data = [
'customerName' => $license->customer_name,
'edition' => $license->edition,
'licenseKey' => $plain,
'expiresAt' => $license->expires_at,
'portalUrl' => config('sonardesk.portal_url') ?: url('/portal'),
'downloadUrl' => route('release'),
];
Mail::send(['html' => 'emails.license-key', 'text' => 'emails.license-key-text'], $data, function ($message) use ($recipient): void {
$message->to($recipient)->subject('Your SonarDesk licence key');
});
$outbox->update(['delivered_at' => now()]);
$delivered++;
}
return $delivered;
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace Modules\Licensing\Services;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
final class LicenseIssuer
{
public function issue(LicenseProduct $product, ?string $name, ?string $email, ?string $provider = null, ?string $orderID = null, ?string $customerID = null, bool $queueDelivery = false): array
{
return DB::transaction(function () use ($product, $name, $email, $provider, $orderID, $customerID, $queueDelivery) {
if ($provider && $orderID) {
$existing = License::where('payment_provider', $provider)->where('external_order_id', $orderID)->first();
if ($existing) {
return [$existing, null];
}
}
$plain = LicenseKey::generate();
$license = License::create([
'license_product_id' => $product->id,
'license_key_hash' => LicenseKey::hash($plain),
'license_key_last4' => substr(LicenseKey::normalize($plain), -4),
'customer_name' => $name,
'customer_email' => $email ? strtolower(trim($email)) : null,
'edition' => $product->edition,
'activation_limit' => $product->activation_limit,
'max_major_version' => $product->max_major_version,
'payment_provider' => $provider,
'external_order_id' => $orderID,
'external_customer_id' => $customerID,
]);
if ($queueDelivery) {
LicenseDeliveryOutbox::updateOrCreate(
['license_id' => $license->id],
['encrypted_license_key' => Crypt::encryptString($plain), 'recipient_email' => $license->customer_email, 'delivered_at' => null]
);
}
return [$license, $plain];
});
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Modules\Licensing\Services;
final class LicenseKey
{
public static function generate(): string
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$raw = '';
for ($i = 0; $i < 20; $i++) {
$raw .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
return implode('-', str_split($raw, 5));
}
public static function normalize(string $value): string
{
return strtoupper(preg_replace('/[^A-Z0-9]/i', '', trim($value)) ?? '');
}
public static function hash(string $value): string
{
return hash('sha256', self::normalize($value));
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
namespace Modules\Licensing\Services;
use Modules\Licensing\Models\License;
use Carbon\CarbonInterface;
use RuntimeException;
final class LicenseSigner
{
public function sign(License $license, string $installationID, ?CarbonInterface $issuedAt = null): string
{
$seed = $this->privateSeed();
$pair = sodium_crypto_sign_seed_keypair($seed);
$secret = sodium_crypto_sign_secretkey($pair);
$payload = [
'customerEmail' => $license->customer_email,
'customerName' => $license->customer_name,
'edition' => $license->edition,
'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'),
'installationID' => strtolower($installationID),
'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'),
'licenseID' => strtolower((string) $license->id),
'maxMajorVersion' => $license->max_major_version,
];
ksort($payload, SORT_STRING);
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$sig = sodium_crypto_sign_detached($json, $secret);
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
}
public function publicKeyBase64(): string
{
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
return base64_encode(sodium_crypto_sign_publickey($pair));
}
private function privateSeed(): string
{
$path = config('sonardesk.private_key_path');
if (! $path || ! is_file($path)) {
throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
}
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.');
}
return $decoded;
}
private static function b64url(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace Modules\Licensing\Services;
use RuntimeException;
final class LicenseTokenVerifier
{
public function verify(string $token): array
{
$parts = explode('.', trim($token));
if (count($parts) !== 3 || $parts[0] !== 'RS3') {
throw new RuntimeException('Invalid licence token.');
}
$payload = $this->decode($parts[1]);
$signature = $this->decode($parts[2]);
// Derive the verification key from the same private seed used by LicenseSigner.
// Keeping a separate SONARDESK_LICENSE_PUBLIC_KEY allowed the two keys to drift:
// activation would sign a perfectly valid token with the new private key, then
// /validate would reject that same token using a stale public key.
$public = $this->publicKeyFromPrivateSeed();
if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) {
throw new RuntimeException('Licence token signature is invalid.');
}
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
}
private function publicKeyFromPrivateSeed(): string
{
$path = config('sonardesk.private_key_path');
if (! $path || ! is_file($path)) {
throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
}
$seed = base64_decode(trim((string) file_get_contents($path)), true);
if ($seed === false || strlen($seed) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.');
}
$pair = sodium_crypto_sign_seed_keypair($seed);
return sodium_crypto_sign_publickey($pair);
}
private function decode(string $value): string
{
$value = strtr($value, '-_', '+/');
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
$out = base64_decode($value, true);
if ($out === false) {
throw new RuntimeException('Invalid Base64URL value.');
}
return $out;
}
}
View File
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Licensing\Support;
class ModuleInfo
{
protected static string $moduleName = 'Licensing';
/**
* defines the Module Name
* @return string
*/
public static function name(): string
{
return static::$moduleName;
}
/**
* define the lowercase version
* @return string
*/
public static function nameLower(): string
{
return strtolower(static::$moduleName);
}
}
View File
View File
+15
View File
@@ -0,0 +1,15 @@
{
"name": "mpba/licensing",
"description": "SonarDesk Licensing module",
"type": "library",
"autoload": {
"psr-4": {
"Modules\\Licensing\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Modules\\Licensing\\Tests\\": "Tests/"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "Licensing",
"alias": "licensing",
"version": "1.0.0",
"description": "SonarDesk Licensing module",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Licensing\\Providers\\LicensingServiceProvider"
],
"aliases": {},
"files": [],
"requires": []
}
+17
View File
@@ -0,0 +1,17 @@
{
"private": true,
"scripts": {
"dev": "npm run development",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch-poll": "npm run watch -- --watch-poll",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "npm run production",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
},
"devDependencies": {
"cross-env": "^7.0",
"laravel-mix": "^5.0.1",
"laravel-mix-merge-manifest": "^0.1.2"
}
}
+11
View File
@@ -0,0 +1,11 @@
const dotenvExpand = require('dotenv-expand');
dotenvExpand(require('dotenv').config({ path: '../../.env'/*, debug: true*/}));
const mix = require('laravel-mix');
mix.js(__dirname + '/Resources/assets/js/app.js', 'js/licensing.js')
.sass( __dirname + '/Resources/assets/sass/app.scss', 'css/licensing.css');
if (mix.inProduction()) {
mix.version();
}