move into sonardesk
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'name' => 'Licensing',
|
||||
'features' =>
|
||||
[
|
||||
],
|
||||
];
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+92
@@ -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');
|
||||
}
|
||||
};
|
||||
+30
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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'] ?? '');
|
||||
}
|
||||
}
|
||||
Executable
+36
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+12
@@ -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
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
Executable
+46
@@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
Executable
+27
@@ -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));
|
||||
}
|
||||
}
|
||||
Executable
+59
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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/"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user