Compare commits
17
Commits
563f0979b3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04c1f9fb26 | ||
|
|
663e311724 | ||
|
|
dd1f144908 | ||
|
|
27af94d8ca | ||
|
|
7ed8c6ef58 | ||
|
|
27b4e53caf | ||
|
|
cd1d3186f0 | ||
|
|
e44a59a91e | ||
|
|
0ca7a2cf1e | ||
|
|
729b78ddb9 | ||
|
|
a88d336991 | ||
|
|
ef9903b7b7 | ||
|
|
38b4a7406f | ||
|
|
5f0d2ceb72 | ||
|
|
23a07be83e | ||
|
|
cd66923c43 | ||
|
|
b4e040999d |
@@ -0,0 +1,6 @@
|
||||
projectKey=Content
|
||||
serverUrl=http://192.168.1.2:9001
|
||||
serverVersion=9.9.8.100196
|
||||
dashboardUrl=http://192.168.1.2:9001/dashboard?id=Content
|
||||
ceTaskId=AaCkx0myX_DhBSeKgkyS
|
||||
ceTaskUrl=http://192.168.1.2:9001/api/ce/task?id=AaCkx0myX_DhBSeKgkyS
|
||||
@@ -0,0 +1,16 @@
|
||||
# ABSDev / SonarDesk integration — development scaffold
|
||||
|
||||
**NOT PRODUCTION READY. DO NOT DEPLOY OVER AN EXISTING LICENSING SERVER.**
|
||||
|
||||
Implemented:
|
||||
- Preserved ABSDev application as base.
|
||||
- Added SonarDesk-host-specific marketing routes and responsive ABSDev-inspired navy/gold views.
|
||||
- Archived licensing source under `integration/sonardesk-legacy/` for a controlled migration. This directory is **not** wired into runtime.
|
||||
|
||||
Not implemented / not verified:
|
||||
- Licensing API, existing download/appcast routes, payment checkout/webhooks, customer identity mapping, backend administration, data migrations and theme-manager integration.
|
||||
- Laravel test suite, browser tests, PHP dependencies and deployment verification.
|
||||
|
||||
**Important:** Existing unscoped ABSDev routes and module routes may still collide with the SonarDesk host. The new domain routes are only an initial frontend scaffold. Do not point production SonarDesk DNS at this build.
|
||||
|
||||
Next steps: inventory and namespace every licensing route, reconcile User schema and Fortify authentication, port modules and database migrations without collisions, wire checkout and webhooks, add regression tests, and test on staging with anonymised production data.
|
||||
@@ -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,18 @@
|
||||
<?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('download_products')) return;
|
||||
foreach (['release_notes' => 'longText', 'minimum_system' => 'text', 'sort_order' => 'integer'] as $column => $type) {
|
||||
if (!Schema::hasColumn('download_products', $column)) {
|
||||
Schema::table('download_products', function (Blueprint $table) use ($column, $type) {
|
||||
$definition = $table->{$type}($column);
|
||||
if ($column === 'sort_order') $definition->default(0); else $definition->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
public function down(): void { /* Preserve existing download metadata. */ }
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<?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('sonardesk_releases')) Schema::create('sonardesk_releases', function(Blueprint $t) {
|
||||
$t->id(); $t->string('version',40); $t->unsignedInteger('build_number')->default(1); $t->string('channel',20)->default('stable'); $t->string('minimum_macos',30)->nullable(); $t->string('file_path')->nullable(); $t->string('original_filename')->nullable(); $t->unsignedBigInteger('file_size')->nullable(); $t->string('sha256',64)->nullable(); $t->text('sparkle_signature')->nullable(); $t->text('release_notes')->nullable(); $t->boolean('is_published')->default(false)->index(); $t->boolean('is_featured')->default(false)->index(); $t->timestamp('published_at')->nullable()->index(); $t->unsignedBigInteger('download_count')->default(0); $t->timestamps(); $t->unique(['version','build_number','channel']);
|
||||
});
|
||||
if (!Schema::hasTable('download_products')) Schema::create('download_products',function(Blueprint $t){$t->id();$t->string('name');$t->text('description')->nullable();$t->string('version')->nullable();$t->string('platform')->nullable();$t->string('file_path')->nullable();$t->string('original_filename')->nullable();$t->unsignedBigInteger('file_size')->default(0);$t->unsignedBigInteger('download_count')->default(0);$t->boolean('is_published')->default(false);$t->timestamps();});
|
||||
}
|
||||
public function down(): void { /* Preserve release and download data on rollback; use an explicit data migration to remove. */ }
|
||||
};
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<?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('download_products')) return;
|
||||
foreach (['release_notes'=>'longText','minimum_system'=>'text','sort_order'=>'integer'] as $column=>$type) {
|
||||
if (!Schema::hasColumn('download_products',$column)) Schema::table('download_products',function(Blueprint $table) use ($column,$type) {
|
||||
$c=$table->{$type}($column); if ($column==='sort_order') $c->default(0); else $c->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
public function down(): void { /* Keep uploaded download metadata intact. */ }
|
||||
};
|
||||
@@ -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,21 @@
|
||||
<?php
|
||||
namespace Modules\Licensing\Http\Controllers\Admin;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Modules\Licensing\Models\{SonarDeskRelease,DownloadProduct};
|
||||
final class DistributionAdminController extends Controller {
|
||||
public function index(){return view('licensing::distribution.admin',['releases'=>SonarDeskRelease::orderByDesc('created_at')->paginate(20),'products'=>DownloadProduct::orderBy('name')->orderByDesc('created_at')->get()]);}
|
||||
private function upload($file,string $folder):array{$path=$file->store('sonardesk-distribution/'.$folder,'local');return ['file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize()];}
|
||||
private function remove(?string $path):void {if($path && str_starts_with($path,'sonardesk-distribution/') && !str_contains($path,'..'))Storage::disk('local')->delete($path);}
|
||||
private function productRules(bool $new):array{return ['name'=>'required|string|max:150','description'=>'nullable|string','version'=>'nullable|string|max:60','platform'=>'nullable|string|max:60','release_notes'=>'nullable|string','minimum_system'=>'nullable|string|max:2000','sort_order'=>'nullable|integer|between:-10000,10000','is_published'=>'nullable|boolean','file'=>[$new?'required':'nullable','file','max:512000']];}
|
||||
private function releaseRules(bool $new):array{return ['version'=>'required|string|max:40','build_number'=>'required|integer|min:1','channel'=>['required',Rule::in(['stable','beta'])],'minimum_macos'=>'nullable|string|max:30','release_notes'=>'nullable|string','sparkle_signature'=>'nullable|string|max:2048','is_published'=>'nullable|boolean','is_featured'=>'nullable|boolean','file'=>[$new?'required':'nullable','file','max:512000','mimes:dmg,zip,pkg']];}
|
||||
public function product(Request $request){$data=$request->validate($this->productRules(true));$file=$request->file('file');$attributes=$this->upload($file,'products');unset($data['file']);DownloadProduct::create(array_merge($data,$attributes,['is_published'=>$request->boolean('is_published'),'sort_order'=>(int)($data['sort_order']??0)]));return back()->with('status','Download added');}
|
||||
public function updateProduct(Request $request,DownloadProduct $product){$data=$request->validate($this->productRules(false));unset($data['file']);$data['is_published']=$request->boolean('is_published');$data['sort_order']=(int)($data['sort_order']??0);if($request->hasFile('file')){$old=$product->file_path;$data=array_merge($data,$this->upload($request->file('file'),'products'));$product->update($data);$this->remove($old);}else{$product->update($data);}return back()->with('status','Download updated');}
|
||||
public function deleteProduct(DownloadProduct $product){$path=$product->file_path;$product->delete();$this->remove($path);return back()->with('status','Download removed');}
|
||||
public function release(Request $request){$data=$request->validate($this->releaseRules(true));$file=$request->file('file');unset($data['file']);$release=SonarDeskRelease::create(array_merge($data,$this->upload($file,'releases'),['sha256'=>hash_file('sha256',$file->getRealPath()),'is_published'=>$request->boolean('is_published'),'is_featured'=>$request->boolean('is_featured'),'published_at'=>$request->boolean('is_published')?now():null]));return back()->with('status','Release '.$release->version.' created');}
|
||||
public function updateRelease(Request $request,SonarDeskRelease $release){$data=$request->validate($this->releaseRules(false));unset($data['file']);$data['is_published']=$request->boolean('is_published');$data['is_featured']=$request->boolean('is_featured');$data['published_at']=$data['is_published']?($release->published_at??now()):null;if($request->hasFile('file')){$file=$request->file('file');$old=$release->file_path;$data=array_merge($data,$this->upload($file,'releases'),['sha256'=>hash_file('sha256',$file->getRealPath())]);$release->update($data);$this->remove($old);}else{$release->update($data);}return back()->with('status','Release updated');}
|
||||
public function deleteRelease(SonarDeskRelease $release){$path=$release->file_path;$release->delete();$this->remove($path);return back()->with('status','Release removed');}
|
||||
public function publish(Request $request,SonarDeskRelease $release){$data=$request->validate(['is_published'=>'required|boolean']);$release->update(['is_published'=>(bool)$data['is_published'],'published_at'=>$data['is_published']?($release->published_at??now()):null]);return back()->with('status','Release status updated');}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?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;
|
||||
use Modules\Licensing\Models\LicenseWebhookEvent;
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
/** Local-only billing integrity audit. Does not contact Paddle or mutate records. */
|
||||
public function billingAudit(): View
|
||||
{
|
||||
$linked = License::query()->whereNotNull('external_subscription_id');
|
||||
|
||||
$duplicateSubscriptions = License::query()
|
||||
->select('payment_provider', 'external_subscription_id')
|
||||
->selectRaw('COUNT(*) AS license_count')
|
||||
->whereNotNull('external_subscription_id')
|
||||
->groupBy('payment_provider', 'external_subscription_id')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->orderByDesc('license_count')
|
||||
->limit(25)
|
||||
->get();
|
||||
|
||||
$duplicateEvents = LicenseWebhookEvent::query()
|
||||
->select('provider', 'external_event_id')
|
||||
->selectRaw('COUNT(*) AS event_count')
|
||||
->whereNotNull('external_event_id')
|
||||
->groupBy('provider', 'external_event_id')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->orderByDesc('event_count')
|
||||
->limit(25)
|
||||
->get();
|
||||
|
||||
return view('licensing::admin.billing-audit', [
|
||||
'linkedCount' => (clone $linked)->count(),
|
||||
'missingProvider' => (clone $linked)->whereNull('payment_provider')->count(),
|
||||
'missingCustomer' => (clone $linked)->whereNull('external_customer_id')->count(),
|
||||
'missingStatus' => (clone $linked)->whereNull('subscription_status')->count(),
|
||||
'eventCount' => LicenseWebhookEvent::query()->count(),
|
||||
'unprocessedEvents' => LicenseWebhookEvent::query()->whereNull('processed_at')->count(),
|
||||
'failedEvents' => LicenseWebhookEvent::query()->whereNotNull('processing_error')->count(),
|
||||
'duplicateSubscriptions' => $duplicateSubscriptions,
|
||||
'duplicateEvents' => $duplicateEvents,
|
||||
'recentFailures' => LicenseWebhookEvent::query()
|
||||
->whereNotNull('processing_error')->latest()->limit(15)
|
||||
->get(['id', 'provider', 'external_event_id', 'event_type', 'processing_error', 'created_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Licensing\Http\Controllers\Admin;
|
||||
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Modules\Licensing\Services\PaddleReadOnlyClient;
|
||||
use Throwable;
|
||||
|
||||
final class PaddleReconciliationController extends Controller
|
||||
{
|
||||
public function index(PaddleReadOnlyClient $paddle): View
|
||||
{
|
||||
$configured = trim((string) config('sonardesk-paddle.api_key')) !== '';
|
||||
$environment = config('sonardesk-paddle.environment', 'sandbox');
|
||||
$rows = [];
|
||||
|
||||
// A bounded, explicit read-only comparison; no local changes or Paddle writes.
|
||||
$licenses = License::query()
|
||||
->where('payment_provider', 'paddle')
|
||||
->whereNotNull('external_subscription_id')
|
||||
->orderByDesc('created_at')
|
||||
->limit(20)
|
||||
->get();
|
||||
|
||||
foreach ($licenses as $license) {
|
||||
$result = [
|
||||
'license' => $license,
|
||||
'remote_status' => null,
|
||||
'result' => $configured ? 'unknown' : 'not configured',
|
||||
'detail' => null,
|
||||
];
|
||||
if ($configured) {
|
||||
try {
|
||||
$subscription = $paddle->subscription((string) $license->external_subscription_id);
|
||||
$result['remote_status'] = (string) ($subscription['status'] ?? 'unknown');
|
||||
$result['result'] = $result['remote_status'] === (string) $license->subscription_status
|
||||
? 'matched' : 'different';
|
||||
} catch (Throwable $e) {
|
||||
report($e);
|
||||
$result['result'] = 'lookup failed';
|
||||
$result['detail'] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
$rows[] = $result;
|
||||
}
|
||||
|
||||
return view('licensing::admin.paddle-reconciliation', compact('rows', 'configured', 'environment'));
|
||||
}
|
||||
}
|
||||
@@ -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,42 @@
|
||||
<?php
|
||||
namespace Modules\Licensing\Http\Controllers;
|
||||
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Contracts\View\View;
|
||||
|
||||
/** Checkout handoff only. Billing/webhook processing remains on the legacy service. */
|
||||
final class CheckoutHandoffController extends Controller
|
||||
{
|
||||
public function index(): View
|
||||
{
|
||||
return view('licensing::checkout.index', ['enabled' => $this->destination() !== null]);
|
||||
}
|
||||
|
||||
public function redirect(): RedirectResponse
|
||||
{
|
||||
$destination = $this->destination();
|
||||
abort_unless($destination !== null, 503, 'Checkout is not configured.');
|
||||
return redirect()->away($destination, 302);
|
||||
}
|
||||
|
||||
private function destination(): ?string
|
||||
{
|
||||
if (! config('sonardesk-integration.checkout_enabled')) {
|
||||
return null;
|
||||
}
|
||||
$url = trim((string) config('sonardesk-integration.checkout_url'));
|
||||
$parts = parse_url($url);
|
||||
if (! is_array($parts) || strtolower($parts['scheme'] ?? '') !== 'https'
|
||||
|| ! isset($parts['host']) || isset($parts['user']) || isset($parts['pass'])
|
||||
|| isset($parts['fragment']) || isset($parts['port'])) {
|
||||
return null;
|
||||
}
|
||||
$host = strtolower(rtrim($parts['host'], '.'));
|
||||
$allowed = array_map(static fn ($value) => strtolower(rtrim((string) $value, '.')), config('sonardesk-integration.allowed_checkout_hosts', []));
|
||||
if (! in_array($host, $allowed, true)) {
|
||||
return null;
|
||||
}
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Licensing\Http\Controllers\Customer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Modules\Licensing\Models\License;
|
||||
|
||||
final class CustomerLicensingController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$email = mb_strtolower(trim((string) $request->user()->email));
|
||||
|
||||
$licenses = License::query()
|
||||
->with('product')
|
||||
->whereRaw('LOWER(customer_email) = ?', [$email])
|
||||
->latest()
|
||||
->paginate(15);
|
||||
|
||||
return view('licensing::customer.index', compact('licenses'));
|
||||
}
|
||||
|
||||
public function show(Request $request, License $license): View
|
||||
{
|
||||
// Return 404, not 403, to avoid disclosing whether a licence exists.
|
||||
abort_unless(
|
||||
hash_equals(
|
||||
mb_strtolower(trim((string) $license->customer_email)),
|
||||
mb_strtolower(trim((string) $request->user()->email))
|
||||
),
|
||||
404
|
||||
);
|
||||
|
||||
$license->load(['product', 'activations']);
|
||||
|
||||
return view('licensing::customer.show', compact('license'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
namespace Modules\Licensing\Http\Controllers;
|
||||
use Illuminate\Routing\Controller;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Modules\Licensing\Models\{SonarDeskRelease,DownloadProduct};
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
final class DistributionController extends Controller {
|
||||
public function index(){ $latest=SonarDeskRelease::published()->where('channel','stable')->orderByDesc('published_at')->first(); $releases=SonarDeskRelease::published()->orderByDesc('published_at')->paginate(12);$products=DownloadProduct::where('is_published',true)->whereNotNull('file_path')->orderBy('name')->orderBy('sort_order')->orderByDesc('created_at')->get();return view('licensing::downloads.index',compact('latest','releases','products')); }
|
||||
public function release(SonarDeskRelease $release){abort_unless($release->is_published && $release->published_at && $release->file_path,404);return view('licensing::downloads.release',compact('release'));}
|
||||
public function product(DownloadProduct $product){abort_unless($product->is_published && $product->file_path,404);return view('licensing::downloads.product',compact('product'));}
|
||||
public function latest():BinaryFileResponse{$r=SonarDeskRelease::published()->where('channel','stable')->orderByDesc('published_at')->firstOrFail();return $this->send($r);}
|
||||
public function download(SonarDeskRelease $release):BinaryFileResponse{abort_unless($release->is_published && $release->published_at,404);return $this->send($release);}
|
||||
public function productDownload(DownloadProduct $product):BinaryFileResponse{abort_unless($product->is_published,404);$path=$this->safePath($product->file_path);$product->increment('download_count');return response()->download($path,$product->original_filename ?: basename($path));}
|
||||
private function safePath(?string $relative):string {abort_unless($relative && preg_match('~^sonardesk-distribution/[a-zA-Z0-9_./-]+$~D',$relative) && !str_contains($relative,'..'),404);$disk=Storage::disk('local');abort_unless($disk->exists($relative),404);$base=realpath($disk->path('sonardesk-distribution'));$file=realpath($disk->path($relative));abort_unless($base && $file && str_starts_with($file,$base.DIRECTORY_SEPARATOR),404);return $file;}
|
||||
private function send(SonarDeskRelease $r):BinaryFileResponse{$path=$this->safePath($r->file_path);$r->increment('download_count');return response()->download($path,$r->original_filename ?: 'SonarDesk-'.$r->version.'.dmg');}
|
||||
public function appcast(){ $releases=SonarDeskRelease::published()->where('channel','stable')->whereNotNull('sparkle_signature')->orderByDesc('published_at')->limit(20)->get();return response()->view('licensing::downloads.appcast',compact('releases'),200,['Content-Type'=>'application/rss+xml; charset=UTF-8','Cache-Control'=>'public, max-age=300']); }
|
||||
}
|
||||
@@ -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'] ?? '');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace Modules\Licensing\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
final class DownloadProduct extends Model {
|
||||
protected $table='download_products';
|
||||
protected $fillable=['name','description','version','platform','file_path','original_filename','file_size','is_published','release_notes','minimum_system','sort_order'];
|
||||
protected function casts():array{return ['is_published'=>'boolean','file_size'=>'integer','sort_order'=>'integer'];}
|
||||
}
|
||||
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,9 @@
|
||||
<?php
|
||||
namespace Modules\Licensing\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
final class SonarDeskRelease extends Model {
|
||||
protected $table='sonardesk_releases';
|
||||
protected $fillable=['version','build_number','channel','minimum_macos','file_path','original_filename','file_size','sha256','sparkle_signature','release_notes','is_published','is_featured','published_at'];
|
||||
protected function casts():array{return ['is_published'=>'boolean','is_featured'=>'boolean','published_at'=>'datetime','file_size'=>'integer','build_number'=>'integer'];}
|
||||
public function scopePublished($query){return $query->where('is_published',true)->whereNotNull('file_path')->whereNotNull('published_at');}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?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->mergeConfigFrom(base_path('config/sonardesk-paddle.php'), 'sonardesk-paddle');
|
||||
$this->mergeConfigFrom(base_path('config/sonardesk-integration.php'), 'sonardesk-integration');
|
||||
$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');
|
||||
Route::group([], __DIR__.'/../Routes/customer.php');
|
||||
Route::group([], __DIR__.'/../Routes/distribution.php');
|
||||
Route::group([], __DIR__.'/../Routes/checkout.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,34 @@
|
||||
@extends('licensing::admin.layout')
|
||||
@section('title', 'SonarDesk Billing Audit')
|
||||
@section('content')
|
||||
<h1>Billing Audit</h1>
|
||||
<p class="muted">Local database checks only. No Paddle API requests, billing changes, webhook replays or licence mutations are performed.</p>
|
||||
<div class="cards">
|
||||
<div class="card"><div class="muted">Subscription-linked licences</div><div class="metric">{{ number_format($linkedCount) }}</div></div>
|
||||
<div class="card"><div class="muted">Missing payment provider</div><div class="metric">{{ number_format($missingProvider) }}</div></div>
|
||||
<div class="card"><div class="muted">Missing customer ID</div><div class="metric">{{ number_format($missingCustomer) }}</div></div>
|
||||
<div class="card"><div class="muted">Missing subscription status</div><div class="metric">{{ number_format($missingStatus) }}</div></div>
|
||||
<div class="card"><div class="muted">Webhook events stored</div><div class="metric">{{ number_format($eventCount) }}</div></div>
|
||||
<div class="card"><div class="muted">Unprocessed events</div><div class="metric">{{ number_format($unprocessedEvents) }}</div></div>
|
||||
<div class="card"><div class="muted">Events with errors</div><div class="metric">{{ number_format($failedEvents) }}</div></div>
|
||||
</div>
|
||||
<h2>Duplicate subscription identifiers (first 25)</h2>
|
||||
<p class="muted">Multiple licences can legitimately share a subscription. Review these before deciding whether a record is incorrect.</p>
|
||||
<div class="table-wrap"><table><thead><tr><th>Provider</th><th>Subscription ID</th><th>Licences</th></tr></thead><tbody>
|
||||
@forelse ($duplicateSubscriptions as $row)
|
||||
<tr><td>{{ $row->payment_provider ?? '—' }}</td><td>{{ $row->external_subscription_id }}</td><td>{{ $row->license_count }}</td></tr>
|
||||
@empty<tr><td colspan="3">No repeated subscription identifiers.</td></tr>@endforelse
|
||||
</tbody></table></div>
|
||||
<h2>Duplicate webhook event identifiers (first 25)</h2>
|
||||
<div class="table-wrap"><table><thead><tr><th>Provider</th><th>Event ID</th><th>Rows</th></tr></thead><tbody>
|
||||
@forelse ($duplicateEvents as $row)
|
||||
<tr><td>{{ $row->provider }}</td><td>{{ $row->external_event_id }}</td><td>{{ $row->event_count }}</td></tr>
|
||||
@empty<tr><td colspan="3">No duplicate event identifiers.</td></tr>@endforelse
|
||||
</tbody></table></div>
|
||||
<h2>Recent processing errors</h2>
|
||||
<div class="table-wrap"><table><thead><tr><th>When</th><th>Provider</th><th>Event type</th><th>Event ID</th><th>Error</th></tr></thead><tbody>
|
||||
@forelse ($recentFailures as $event)
|
||||
<tr><td>{{ $event->created_at?->format('d M Y H:i') ?? '—' }}</td><td>{{ $event->provider }}</td><td>{{ $event->event_type }}</td><td>{{ $event->external_event_id }}</td><td>{{ \Illuminate\Support\Str::limit($event->processing_error, 240) }}</td></tr>
|
||||
@empty<tr><td colspan="5">No stored processing errors.</td></tr>@endforelse
|
||||
</tbody></table></div>
|
||||
@endsection
|
||||
@@ -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,40 @@
|
||||
{{-- 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-orange-600 dark:text-orange-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">Manage licensing, releases and product downloads</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>
|
||||
@if (\Illuminate\Support\Facades\Route::has('administration.sonardesk.distribution'))
|
||||
<a class="rounded-lg border border-orange-500 bg-orange-500 px-4 py-2 font-semibold text-white hover:bg-orange-600" href="{{ route('administration.sonardesk.distribution') }}">Downloads & releases</a>
|
||||
@endif
|
||||
</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:#f97316;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:#f97316}
|
||||
.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,22 @@
|
||||
@extends('licensing::admin.layout')
|
||||
@section('title', 'Paddle Reconciliation')
|
||||
@section('content')
|
||||
<h1>Paddle Reconciliation</h1>
|
||||
<p class="muted">Read-only comparison of the 20 most recent Paddle-linked licences against Paddle {{ $environment }}. This page never changes subscriptions, licences or webhooks.</p>
|
||||
@if (! $configured)
|
||||
<div class="card"><strong>Paddle API not configured.</strong><p>Set <code>SONARDESK_PADDLE_API_KEY</code> and <code>SONARDESK_PADDLE_ENVIRONMENT</code> in your local environment, then clear configuration cache. No API calls were made.</p></div>
|
||||
@endif
|
||||
<div class="table-wrap"><table><thead><tr><th>Licence</th><th>Subscription</th><th>Local status</th><th>Paddle status</th><th>Comparison</th></tr></thead><tbody>
|
||||
@forelse ($rows as $row)
|
||||
<tr>
|
||||
<td><a href="{{ route('administration.sonardesk.licenses.show', $row['license']) }}">{{ $row['license']->license_key_last4 ?? $row['license']->id }}</a></td>
|
||||
<td>{{ $row['license']->external_subscription_id }}</td>
|
||||
<td>{{ $row['license']->subscription_status ?? '—' }}</td>
|
||||
<td>{{ $row['remote_status'] ?? '—' }}</td>
|
||||
<td><span class="pill">{{ $row['result'] }}</span>@if ($row['detail']) <span class="muted">{{ $row['detail'] }}</span> @endif</td>
|
||||
</tr>
|
||||
@empty
|
||||
<tr><td colspan="5">No Paddle-linked licences found in the local database.</td></tr>
|
||||
@endforelse
|
||||
</tbody></table></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,3 @@
|
||||
@extends('sonardesk.layout')
|
||||
@section('title','SonarDesk — Purchase')
|
||||
@section('content')<section class="sd-pagehead"><div class="sd-container"><span class="sd-eyebrow">SonarDesk licensing</span><h1>Get SonarDesk</h1><p>Choose your licence using the existing secure SonarDesk checkout service.</p></div></section><section class="sd-section"><div class="sd-container sd-detail"><div class="sd-card"><h2>Purchase a licence</h2><p>Your subscription, billing and licence are managed by the established SonarDesk service.</p>@if($enabled)<a class="sd-btn sd-btn-dark" href="{{ route('sonardesk.checkout') }}">View plans & buy →</a>@else<p class="sd-note">Online checkout is not enabled on this site yet. No payment will be taken here.</p>@endif<div class="sd-card-actions"><a class="sd-textlink" href="{{ route('sonardesk.downloads') }}">Downloads & release notes →</a><a class="sd-textlink" href="{{ route('account.sonardesk.index') }}">My licences →</a></div></div></div></section>@endsection
|
||||
@@ -0,0 +1,14 @@
|
||||
@extends('licensing::customer.layout')
|
||||
@section('title', 'My SonarDesk Licences')
|
||||
@section('content')
|
||||
<h1>My SonarDesk licences</h1>
|
||||
<p class="muted">Licences associated with your verified ABSDev account email. This portal is read-only; existing billing and licence activation services remain unchanged.</p>
|
||||
<div class="card scroll"><table><thead><tr><th>Edition</th><th>Licence</th><th>Subscription</th><th>Expiry</th><th></th></tr></thead><tbody>
|
||||
@forelse($licenses as $license)
|
||||
<tr><td><strong>{{ $license->edition }}</strong></td><td>•••• {{ $license->license_key_last4 ?? '—' }}</td><td><span class="pill">{{ $license->subscription_status ?? 'Not linked' }}</span></td><td>{{ $license->expires_at?->format('d M Y') ?? '—' }}</td><td><a href="{{ route('account.sonardesk.licenses.show', $license) }}">View details</a></td></tr>
|
||||
@empty
|
||||
<tr><td colspan="5">No SonarDesk licences match your account email. If you purchased with a different email, contact support to verify ownership; licences cannot be claimed automatically.</td></tr>
|
||||
@endforelse
|
||||
</tbody></table></div>
|
||||
{{ $licenses->links() }}
|
||||
@endsection
|
||||
@@ -0,0 +1,7 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>@yield('title', 'SonarDesk Account') · ABSDev</title>
|
||||
<style>
|
||||
:root{color-scheme:light dark;font-family:system-ui,-apple-system,sans-serif}body{margin:0;background:#f5f7fb;color:#19253a}header{background:#112440;color:#fff;padding:18px max(20px,calc((100vw - 1100px)/2))}header a{color:#e8c36b;text-decoration:none}main{max-width:1100px;margin:35px auto;padding:0 20px}.card{background:#fff;border:1px solid #dbe2ee;border-radius:12px;padding:22px;margin:16px 0;box-shadow:0 4px 16px #10233d0a}a{color:#19579b}table{border-collapse:collapse;width:100%}th,td{padding:12px;text-align:left;border-bottom:1px solid #e5eaf2}th{color:#53627b}small,.muted{color:#59677e}.scroll{overflow-x:auto}.pill{border-radius:99px;background:#eef3fa;padding:5px 10px;display:inline-block}h1{font-size:1.8rem}@media(prefers-color-scheme:dark){body{background:#0e1725;color:#e9eef8}.card{background:#18263b;border-color:#33435a}th,td{border-color:#33435a}th,.muted,small{color:#bdc7d8}.pill{background:#33435a}a{color:#a8cfff}}
|
||||
</style></head><body><header><strong>ABSDev · SonarDesk</strong> <a href="{{ route('account.sonardesk.index') }}">My licences</a></header>
|
||||
<main>@yield('content')</main></body></html>
|
||||
@@ -0,0 +1,12 @@
|
||||
@extends('licensing::customer.layout')
|
||||
@section('title', 'Licence Details')
|
||||
@section('content')
|
||||
<p><a href="{{ route('account.sonardesk.index') }}">← My licences</a></p>
|
||||
<h1>SonarDesk {{ $license->edition }}</h1>
|
||||
<div class="card"><h2>Licence</h2><p><strong>Key ending:</strong> {{ $license->license_key_last4 ?? '—' }}</p><p><strong>Status:</strong> {{ $license->revoked_at ? 'Revoked' : ($license->is_usable ? 'Usable' : 'Expired') }}</p><p><strong>Subscription:</strong> {{ $license->subscription_status ?? 'Not linked' }}</p><p><strong>Current period ends:</strong> {{ $license->current_period_ends_at?->format('d M Y') ?? '—' }}</p><p><strong>Activation limit:</strong> {{ $license->activation_limit }}</p><p class="muted">Full licence keys are not displayed or recoverable from their stored hashes.</p></div>
|
||||
<div class="card scroll"><h2>Devices</h2><table><thead><tr><th>Installation</th><th>Status</th></tr></thead><tbody>
|
||||
@forelse($license->activations as $activation)
|
||||
<tr><td>{{ $activation->installation_id ?? $activation->installationID ?? 'Device' }}</td><td>{{ $activation->deactivated_at ? 'Deactivated' : 'Active' }}</td></tr>
|
||||
@empty<tr><td colspan="2">No activations recorded.</td></tr>@endforelse
|
||||
</tbody></table></div>
|
||||
@endsection
|
||||
@@ -0,0 +1,12 @@
|
||||
@extends('licensing::admin.layout')
|
||||
@section('title','Downloads & releases')
|
||||
@section('content')
|
||||
<div class="sd-admin"><div class="sd-admin-head"><div><p class="sd-eyebrow">PRODUCT DISTRIBUTION</p><h1>Downloads & releases</h1><p>Manage SonarDesk releases, SonarDisk and alternative installers from one place.</p></div><a href="{{ route('sonardesk.downloads') }}" target="_blank" rel="noopener" class="sd-link">View public downloads ↗</a></div>
|
||||
@if(session('status'))<div class="sd-notice">{{ session('status') }}</div>@endif
|
||||
@if($errors->any())<div class="sd-errors"><strong>Unable to save:</strong><ul>@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul></div>@endif
|
||||
<div class="sd-two"><section class="sd-panel"><h2>Add SonarDesk release</h2><form method="POST" enctype="multipart/form-data" action="{{ route('administration.sonardesk.distribution.releases.store') }}">@csrf<div class="sd-grid"><label>Version<input name="version" required placeholder="1.2.0" value="{{ old('version') }}"></label><label>Build<input name="build_number" type="number" min="1" required value="{{ old('build_number') }}"></label><label>Channel<select name="channel"><option value="stable">Stable</option><option value="beta">Beta</option></select></label><label>Minimum macOS<input name="minimum_macos" placeholder="14.0"></label></div><label>Full release notes<textarea name="release_notes" rows="4"></textarea></label><label>Sparkle signature (optional)<input name="sparkle_signature"></label><label>Installer (.dmg, .zip, .pkg; up to 500 MB)<input type="file" name="file" required accept=".dmg,.zip,.pkg"></label><div class="sd-actions"><label class="sd-check"><input type="checkbox" name="is_published" value="1"> Publish now</label><button>Create release</button></div></form></section>
|
||||
<section class="sd-panel"><h2>Add product / alternative download</h2><form method="POST" enctype="multipart/form-data" action="{{ route('administration.sonardesk.distribution.products.store') }}">@csrf<div class="sd-grid"><label>Product name<input name="name" required placeholder="SonarDisk"></label><label>Version<input name="version" placeholder="1.0"></label><label>Platform<input name="platform" placeholder="macOS · Apple Silicon"></label><label>Display order<input name="sort_order" type="number" value="0"></label></div><label>Short description<textarea name="description" rows="2"></textarea></label><label>Full release notes<textarea name="release_notes" rows="3"></textarea></label><label>System requirements<input name="minimum_system" placeholder="macOS 14 or later"></label><label>Download file (up to 500 MB)<input name="file" type="file" required></label><div class="sd-actions"><label class="sd-check"><input type="checkbox" name="is_published" value="1"> Publish now</label><button>Add download</button></div></form></section></div>
|
||||
<section class="sd-panel"><h2>Manage products and alternative downloads <small>({{ $products->count() }})</small></h2><p class="sd-help">Add multiple files under the same product name to group them on the public page. Expand an entry to edit, replace its file or unpublish it.</p>@forelse($products as $p)<details class="sd-entry"><summary><strong>{{ $p->name }}</strong><span>{{ $p->version ?: 'Unversioned' }} · {{ $p->platform ?: 'Any platform' }}</span><span class="sd-status {{ $p->is_published ? 'live' : '' }}">{{ $p->is_published ? 'Published' : 'Draft' }}</span><span>{{ number_format($p->download_count) }} downloads</span></summary><form method="POST" enctype="multipart/form-data" action="{{ route('administration.sonardesk.distribution.products.update',$p) }}">@csrf @method('PATCH')<div class="sd-grid"><label>Name<input name="name" value="{{ $p->name }}" required></label><label>Version<input name="version" value="{{ $p->version }}"></label><label>Platform<input name="platform" value="{{ $p->platform }}"></label><label>Order<input type="number" name="sort_order" value="{{ $p->sort_order ?? 0 }}"></label></div><label>Description<textarea name="description" rows="2">{{ $p->description }}</textarea></label><label>Release notes<textarea name="release_notes" rows="4">{{ $p->release_notes }}</textarea></label><label>System requirements<input name="minimum_system" value="{{ $p->minimum_system }}"></label><label>Replace file (optional) — currently {{ $p->original_filename }}<input type="file" name="file"></label><div class="sd-actions"><label class="sd-check"><input type="checkbox" name="is_published" value="1" @checked($p->is_published)> Published</label><button>Save changes</button><a href="{{ route('sonardesk.products.show',$p) }}" target="_blank" rel="noopener">Public details ↗</a></div></form><form method="POST" action="{{ route('administration.sonardesk.distribution.products.delete',$p) }}" onsubmit="return confirm('Permanently delete this download and its file?')">@csrf @method('DELETE')<button class="sd-danger">Delete download</button></form></details>@empty<p>No additional downloads yet. Add SonarDisk or an alternative installer above.</p>@endforelse</section>
|
||||
<section class="sd-panel"><h2>Manage SonarDesk releases</h2>@forelse($releases as $r)<details class="sd-entry"><summary><strong>SonarDesk {{ $r->version }}</strong><span>Build {{ $r->build_number }} · {{ ucfirst($r->channel) }}</span><span class="sd-status {{ $r->is_published ? 'live' : '' }}">{{ $r->is_published ? 'Published' : 'Draft' }}</span><span>{{ number_format($r->download_count) }} downloads</span></summary><form method="POST" enctype="multipart/form-data" action="{{ route('administration.sonardesk.distribution.releases.update',$r) }}">@csrf @method('PATCH')<div class="sd-grid"><label>Version<input name="version" required value="{{ $r->version }}"></label><label>Build<input type="number" min="1" name="build_number" required value="{{ $r->build_number }}"></label><label>Channel<select name="channel"><option value="stable" @selected($r->channel==='stable')>Stable</option><option value="beta" @selected($r->channel==='beta')>Beta</option></select></label><label>Minimum macOS<input name="minimum_macos" value="{{ $r->minimum_macos }}"></label></div><label>Release notes<textarea name="release_notes" rows="5">{{ $r->release_notes }}</textarea></label><label>Sparkle signature<input name="sparkle_signature" value="{{ $r->sparkle_signature }}"></label><label>Replace installer (optional) — {{ $r->original_filename }}<input type="file" name="file" accept=".dmg,.zip,.pkg"></label><div class="sd-actions"><label class="sd-check"><input type="checkbox" name="is_published" value="1" @checked($r->is_published)> Published</label><label class="sd-check"><input type="checkbox" name="is_featured" value="1" @checked($r->is_featured)> Featured</label><button>Save release</button></div></form><form method="POST" action="{{ route('administration.sonardesk.distribution.releases.delete',$r) }}" onsubmit="return confirm('Permanently delete this release and its installer?')">@csrf @method('DELETE')<button class="sd-danger">Delete release</button></form></details>@empty<p>No SonarDesk releases yet.</p>@endforelse<div class="sd-pager">{{ $releases->links() }}</div></section></div>
|
||||
<style>.sd-admin{max-width:1480px;margin:auto}.sd-admin-head{display:flex;justify-content:space-between;gap:20px;align-items:center;margin-bottom:25px}.sd-admin-head h1{font-size:2rem;margin:4px 0}.sd-admin-head p{color:#71717a}.sd-eyebrow{font-size:11px;letter-spacing:.18em;color:#f97316!important;font-weight:800}.sd-two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px}.sd-panel{border:1px solid #a1a1aa55;border-radius:16px;padding:25px;margin-bottom:20px;background:rgba(127,127,127,.035)}.sd-panel h2{font-size:1.25rem;margin:0 0 20px}.sd-panel label{display:block;font-size:13px;font-weight:650;margin-bottom:14px}.sd-panel input:not([type=checkbox]),.sd-panel select,.sd-panel textarea{display:block;width:100%;margin-top:6px;padding:10px 12px;border:1px solid #a1a1aa88;border-radius:8px;background:transparent;color:inherit}.sd-panel select option{color:#111}.sd-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 14px}.sd-actions{display:flex;gap:15px;align-items:center;flex-wrap:wrap}.sd-panel .sd-check{display:flex;align-items:center;gap:8px;margin:0}.sd-panel button:not(.sd-danger){background:#f97316;color:#fff;border:0;padding:10px 18px;border-radius:8px;font-weight:750;cursor:pointer}.sd-panel .sd-danger{background:transparent;border:1px solid #dc2626;color:#dc2626;padding:8px 12px;border-radius:8px;margin-top:12px;cursor:pointer}.sd-entry{border-top:1px solid #a1a1aa55;padding:15px 0}.sd-entry summary{cursor:pointer;display:flex;gap:20px;align-items:center;flex-wrap:wrap}.sd-entry summary strong{min-width:170px}.sd-entry summary span{font-size:13px;color:#71717a}.sd-entry[open] summary{margin-bottom:20px}.sd-status{padding:4px 9px;border-radius:30px;background:#a1a1aa33}.sd-status.live{background:#16a34a22!important;color:#16a34a!important}.sd-notice,.sd-errors{padding:14px;border-radius:8px;margin-bottom:18px;background:#16a34a22}.sd-errors{background:#dc262622}.sd-help{color:#71717a;margin-bottom:18px}.sd-link{white-space:nowrap}.sd-pager{margin-top:20px}@media(max-width:900px){.sd-two{grid-template-columns:1fr}}@media(max-width:540px){.sd-grid{grid-template-columns:1fr}.sd-admin-head{display:block}}</style>
|
||||
@endsection
|
||||
@@ -0,0 +1,2 @@
|
||||
@php echo '<?xml version="1.0" encoding="utf-8"?>'; @endphp
|
||||
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><title>SonarDesk Updates</title><link>{{ route('sonardesk.updates.appcast') }}</link><description>SonarDesk stable release feed</description>@foreach($releases as $release)<item><title>SonarDesk {{ $release->version }}</title><pubDate>{{ $release->published_at?->toRfc2822String() }}</pubDate><sparkle:version>{{ $release->build_number }}</sparkle:version><sparkle:shortVersionString>{{ $release->version }}</sparkle:shortVersionString>@if($release->minimum_macos)<sparkle:minimumSystemVersion>{{ $release->minimum_macos }}</sparkle:minimumSystemVersion>@endif<description><![CDATA[{!! nl2br(e($release->release_notes ?: 'SonarDesk update')) !!}]]></description><enclosure url="{{ route('sonardesk.releases.download',$release) }}" length="{{ $release->file_size }}" type="application/octet-stream" sparkle:edSignature="{{ $release->sparkle_signature }}" /></item>@endforeach</channel></rss>
|
||||
@@ -0,0 +1,6 @@
|
||||
@extends('sonardesk.layout')
|
||||
@section('title','Software downloads and releases')
|
||||
@section('content')<div class="product-shell"><header class="page-heading"><span class="eyebrow">ABSDEV Software Library</span><h1>Downloads & releases.</h1><p>Find SonarDesk, SonarDisk and alternative installers, grouped by product. Release notes and system requirements are on the detail pages.</p></header>
|
||||
@if($latest)<section class="download-group"><span class="eyebrow">Latest stable · SonarDesk</span><div class="download-group-head"><div><h2>SonarDesk {{ $latest->version }}</h2><p>{{ \Illuminate\Support\Str::limit(strip_tags($latest->release_notes ?: 'Native SonarQube monitoring for macOS.'), 180) }}</p></div><span class="badge">Latest release</span></div><div class="actions"><a class="button" href="{{ route('sonardesk.releases.download',$latest) }}">Download installer ↓</a><a class="button outline" href="{{ route('sonardesk.releases.show',$latest) }}">Full release notes →</a></div></section>@endif
|
||||
@if($products->isNotEmpty())<section class="product-section" style="padding-top:40px"><div class="section-heading"><span class="eyebrow">Product catalogue</span><h2>Products & alternative downloads</h2><p>Each product lists its available versions and platforms together.</p></div>@foreach($products->groupBy('name') as $name=>$files)<article class="download-group"><div class="download-group-head"><div><h2>{{ $name }}</h2><p>{{ $files->first()->description ?: 'Installers and supporting downloads.' }}</p></div><span class="badge">{{ $files->count() }} {{ $files->count() === 1 ? 'file' : 'files' }}</span></div>@foreach($files as $file)<div class="download-row"><div><strong>{{ $file->version ? 'Version '.$file->version : ($file->original_filename ?: 'Download') }}</strong><small>{{ $file->platform ?: 'All platforms' }} @if($file->file_size) · {{ number_format($file->file_size/1048576,1) }} MB @endif</small><p>{{ \Illuminate\Support\Str::limit(strip_tags($file->release_notes ?: $file->description ?: ''),120) }}</p></div><div class="row-actions"><a class="text-link" href="{{ route('sonardesk.products.show',$file) }}">Details →</a><a class="button small" href="{{ route('sonardesk.products.download',$file) }}">Download ↓</a></div></div>@endforeach</article>@endforeach</section>@endif
|
||||
<section class="product-section" style="padding-top:20px"><div class="section-heading"><span class="eyebrow">Release archive</span><h2>SonarDesk release history</h2></div><div class="download-group">@forelse($releases as $release)<div class="download-row"><div><strong>SonarDesk {{ $release->version }}</strong><small>Build {{ $release->build_number }} · {{ ucfirst($release->channel) }}</small><p>{{ \Illuminate\Support\Str::limit(strip_tags($release->release_notes ?: 'Release information'),125) }}</p></div><div class="row-actions"><a class="text-link" href="{{ route('sonardesk.releases.show',$release) }}">Release notes →</a><a class="button small" href="{{ route('sonardesk.releases.download',$release) }}">Download ↓</a></div></div>@empty<p>No SonarDesk releases have been published yet.</p>@endforelse</div><div class="pagination">{{ $releases->links() }}</div></section></div>@endsection
|
||||
@@ -0,0 +1,3 @@
|
||||
@extends('sonardesk.layout')
|
||||
@section('title',$product->name.' download')
|
||||
@section('content')<div class="product-shell"><header class="page-heading"><a class="text-link" href="{{ route('sonardesk.downloads') }}">← All downloads</a><p class="eyebrow">Product download</p><h1>{{ $product->name }}</h1><p>{{ $product->description }}</p></header><article class="detail-panel"><span class="badge">{{ $product->platform ?: 'All platforms' }}</span><p class="muted">Version {{ $product->version ?: 'Unspecified' }}</p><a class="button" href="{{ route('sonardesk.products.download',$product) }}">Download {{ $product->original_filename }} ↓</a>@if($product->release_notes)<h2>Full release notes</h2><div class="preformatted">{{ $product->release_notes }}</div>@endif@if($product->minimum_system)<h2>System requirements</h2><div class="preformatted">{{ $product->minimum_system }}</div>@endif</article></div>@endsection
|
||||
@@ -0,0 +1,3 @@
|
||||
@extends('sonardesk.layout')
|
||||
@section('title','SonarDesk '.$release->version.' release notes')
|
||||
@section('content')<div class="product-shell"><header class="page-heading"><a class="text-link" href="{{ route('sonardesk.downloads') }}">← All downloads</a><p class="eyebrow">SonarDesk · {{ strtoupper($release->channel) }}</p><h1>SonarDesk {{ $release->version }}</h1><p>Build {{ $release->build_number }}</p></header><article class="detail-panel"><a class="button" href="{{ route('sonardesk.releases.download',$release) }}">Download {{ $release->original_filename }} ↓</a><h2>Full release notes</h2><div class="preformatted">{{ $release->release_notes ?: 'No release notes provided.' }}</div><h2>System requirements</h2><p>macOS {{ $release->minimum_macos ?: 'See application documentation' }} or later.</p>@if($release->sha256)<h2>File verification · SHA-256</h2><code class="hash">{{ $release->sha256 }}</code>@endif</article></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,17 @@
|
||||
<?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('/billing-audit', [LicensingDashboardController::class, 'billingAudit'])->name('billing-audit');
|
||||
Route::get('/paddle-reconciliation', [\Modules\Licensing\Http\Controllers\Admin\PaddleReconciliationController::class, 'index'])->name('paddle-reconciliation');
|
||||
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,8 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Licensing\Http\Controllers\CheckoutHandoffController;
|
||||
|
||||
Route::middleware(['web', 'throttle:30,1'])->prefix('sonardesk')->name('sonardesk.')->group(function (): void {
|
||||
Route::get('/buy', [CheckoutHandoffController::class, 'index'])->name('buy');
|
||||
Route::get('/checkout', [CheckoutHandoffController::class, 'redirect'])->name('checkout');
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Licensing\Http\Controllers\Customer\CustomerLicensingController;
|
||||
|
||||
// Read-only customer portal. No licence claiming by ID, no billing mutations.
|
||||
Route::middleware(['web', 'auth', 'verified', 'throttle:30,1'])
|
||||
->prefix('account/sonardesk')
|
||||
->name('account.sonardesk.')
|
||||
->group(function (): void {
|
||||
Route::get('/', [CustomerLicensingController::class, 'index'])->name('index');
|
||||
Route::get('/licenses/{license}', [CustomerLicensingController::class, 'show'])->name('licenses.show');
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Licensing\Http\Controllers\DistributionController as PublicDownloads;
|
||||
use Modules\Licensing\Http\Controllers\Admin\DistributionAdminController as AdminDownloads;
|
||||
Route::middleware(['web','throttle:60,1'])->prefix('sonardesk')->name('sonardesk.')->group(function(){
|
||||
Route::get('/downloads',[PublicDownloads::class,'index'])->name('downloads');
|
||||
Route::get('/downloads/releases/{release}',[PublicDownloads::class,'release'])->name('releases.show');
|
||||
Route::get('/downloads/products/{product}',[PublicDownloads::class,'product'])->name('products.show');
|
||||
Route::get('/downloads/latest',[PublicDownloads::class,'latest'])->name('latest');
|
||||
Route::get('/downloads/releases/{release}/file',[PublicDownloads::class,'download'])->name('releases.download');
|
||||
Route::get('/downloads/products/{product}/file',[PublicDownloads::class,'productDownload'])->name('products.download');
|
||||
Route::get('/updates/appcast.xml',[PublicDownloads::class,'appcast'])->name('updates.appcast');
|
||||
});
|
||||
Route::middleware(['web','auth','verified','can:manage-sonardesk-licensing'])->prefix('administration/sonardesk')->name('administration.sonardesk.')->group(function(){
|
||||
Route::get('/distribution',[AdminDownloads::class,'index'])->name('distribution');
|
||||
Route::post('/distribution/releases',[AdminDownloads::class,'release'])->name('distribution.releases.store');
|
||||
Route::post('/distribution/products',[AdminDownloads::class,'product'])->name('distribution.products.store');
|
||||
Route::patch('/distribution/products/{product}',[AdminDownloads::class,'updateProduct'])->name('distribution.products.update');
|
||||
Route::delete('/distribution/products/{product}',[AdminDownloads::class,'deleteProduct'])->name('distribution.products.delete');
|
||||
Route::patch('/distribution/releases/{release}',[AdminDownloads::class,'updateRelease'])->name('distribution.releases.update');
|
||||
Route::delete('/distribution/releases/{release}',[AdminDownloads::class,'deleteRelease'])->name('distribution.releases.delete');
|
||||
Route::patch('/distribution/releases/{release}/publish',[AdminDownloads::class,'publish'])->name('distribution.releases.publish');
|
||||
});
|
||||
@@ -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,49 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Licensing\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
|
||||
/** Read-only Paddle Billing API access; no writes or webhook registration. */
|
||||
final class PaddleReadOnlyClient
|
||||
{
|
||||
public function subscription(string $id): array
|
||||
{
|
||||
if (! preg_match('/^sub_[a-zA-Z0-9]+$/', $id)) {
|
||||
throw new RuntimeException('Invalid Paddle subscription identifier.');
|
||||
}
|
||||
|
||||
$token = trim((string) config('sonardesk-paddle.api_key'));
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('SONARDESK_PADDLE_API_KEY is not configured.');
|
||||
}
|
||||
|
||||
$environment = config('sonardesk-paddle.environment', 'sandbox');
|
||||
if (! in_array($environment, ['sandbox', 'production'], true)) {
|
||||
throw new RuntimeException('Invalid Paddle environment configuration.');
|
||||
}
|
||||
|
||||
$url = $environment === 'production'
|
||||
? 'https://api.paddle.com'
|
||||
: 'https://sandbox-api.paddle.com';
|
||||
|
||||
$response = Http::baseUrl($url)
|
||||
->withToken($token)
|
||||
->acceptJson()
|
||||
->timeout(8)
|
||||
->retry(2, 200, throw: false)
|
||||
->get('/subscriptions/'.$id);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new RuntimeException('Paddle lookup failed (HTTP '.$response->status().').');
|
||||
}
|
||||
|
||||
$data = $response->json('data');
|
||||
if (! is_array($data)) {
|
||||
throw new RuntimeException('Unexpected Paddle response.');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -6,4 +6,5 @@ use App\Providers\FortifyServiceProvider;
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
FortifyServiceProvider::class,
|
||||
\Modules\Licensing\Providers\IntegratedLicensingServiceProvider::class,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
return [
|
||||
// Off by default. The existing SonarDesk service remains the billing authority.
|
||||
'checkout_enabled' => (bool) env('SONARDESK_CHECKOUT_ENABLED', false),
|
||||
'checkout_url' => env('SONARDESK_CHECKOUT_URL', ''),
|
||||
'allowed_checkout_hosts' => array_values(array_filter(array_map('trim', explode(',', (string) env('SONARDESK_CHECKOUT_ALLOWED_HOSTS', ''))))),
|
||||
];
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'api_key' => env('SONARDESK_PADDLE_API_KEY', ''),
|
||||
'environment' => env('SONARDESK_PADDLE_ENVIRONMENT', 'sandbox'),
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
# ABSDev / SonarDesk integration — development scaffold
|
||||
|
||||
**NOT PRODUCTION READY. DO NOT DEPLOY OVER AN EXISTING LICENSING SERVER.**
|
||||
|
||||
Implemented:
|
||||
- Preserved ABSDev application as base.
|
||||
- Added SonarDesk-host-specific marketing routes and responsive ABSDev-inspired navy/gold views.
|
||||
- Archived licensing source under `sonardesk-legacy` for a controlled migration. This directory is **not** wired into runtime.
|
||||
|
||||
Not implemented / not verified:
|
||||
- Licensing API, existing download/appcast routes, payment checkout/webhooks, customer identity mapping, backend administration, data migrations and theme-manager integration.
|
||||
- Laravel test suite, browser tests, PHP dependencies and deployment verification.
|
||||
|
||||
**Important:** Existing unscoped ABSDev routes and module routes may still collide with the SonarDesk host. The new domain routes are only an initial frontend scaffold. Do not point production SonarDesk DNS at this build.
|
||||
|
||||
Next steps: inventory and namespace every licensing route, reconcile User schema and Fortify authentication, port modules and database migrations without collisions, wire checkout and webhooks, add regression tests, and test on staging with anonymised production data.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Phase 2: route-contract baseline (not a runtime migration)
|
||||
|
||||
This iteration adds a repeatable **static** route inventory (`sonardesk-route-inventory.json`), its regeneration script (`php scripts/integration/audit_routes.php`) and host-level marketing route tests.
|
||||
|
||||
## Production blockers
|
||||
|
||||
1. Extract legacy API routes from module route providers and compare the **runtime** `php artisan route:list --json` outputs of both original applications. Static regex is not a substitute for runtime route enumeration.
|
||||
2. Port licensing models, migrations, services, controllers and commands with namespace isolation. Do not run legacy migrations against the ABSDev database until table/schema conflicts have been reviewed.
|
||||
3. Reconcile Fortify/Spatie auth with legacy users, permissions, sessions and licence ownership.
|
||||
4. Integrate Paddle/Lemon Squeezy webhook signature verification and idempotency, preserve existing endpoints and secrets outside source control.
|
||||
5. Port downloads, appcast, support and release-management views/controllers into Stylist's SonarDesk theme.
|
||||
6. Test with a staging database copy and synthetic payment events; only then switch DNS.
|
||||
|
||||
No legacy licensing endpoints have been activated by this change. The archived legacy source remains isolated.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Phase 10 — Read-only customer licence portal
|
||||
|
||||
## Install
|
||||
Extract this archive at the ABSDev project root, preserving paths. Merge `IntegratedLicensingServiceProvider.php` if locally customised. Do not replace the production licensing application.
|
||||
|
||||
```sh
|
||||
composer dump-autoload
|
||||
php artisan optimize:clear
|
||||
php artisan route:list --path=account/sonardesk -v
|
||||
```
|
||||
|
||||
Visit `/account/sonardesk` while signed in with a **verified** ABSDev account. Licences are matched to `customer_email` case-insensitively; no cross-application SSO or automatic account linking is claimed.
|
||||
|
||||
## Security and scope
|
||||
- Authenticated and verified users only, rate-limited.
|
||||
- Ownership checked on each licence detail request, returning 404 for non-owners.
|
||||
- No subscription cancellation, upgrade, activation changes, key retrieval or checkout.
|
||||
- No new migrations, secrets or third-party API calls.
|
||||
- Existing Paddle billing, licensing APIs, admin routes and webhooks are untouched.
|
||||
- Test with one owner, one unrelated user and an unverified user before release.
|
||||
|
||||
## Phase 11
|
||||
Downloads, releases and update feeds.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Phase 11 — SonarDesk distribution
|
||||
Install patch at ABSDEV root; all paths are relative to the project root. No root-level module duplicates are included.
|
||||
|
||||
```sh
|
||||
php artisan migrate
|
||||
composer dump-autoload
|
||||
php artisan optimize:clear
|
||||
php artisan route:list --path=sonardesk
|
||||
php artisan route:list --path=administration/sonardesk/distribution
|
||||
```
|
||||
Admin: `/administration/sonardesk/distribution`; public: `/sonardesk/downloads`; feed: `/sonardesk/updates/appcast.xml`.
|
||||
|
||||
Release and download binaries are stored on the private `local` disk in `sonardesk-distribution/`. Ensure the storage directory is persistent across deployments, backed up, and writable by PHP. Do not put it in public webroot. Public download responses use the Laravel controller.
|
||||
|
||||
The database schema preserves the existing legacy `sonardesk_releases` and `download_products` column names. If those tables already exist, the migration does not recreate them. Legacy files and records are **not** automatically imported; migrate their storage paths into `sonardesk-distribution/` before enabling public downloads.
|
||||
|
||||
Sparkle appcast includes only stable published releases with a non-empty Sparkle signature. Generate signatures with the official Sparkle signing tool; SHA-256 is not a substitute. Configure the macOS client to point to this feed only after validating compatibility. Existing live appcast and checkout remain unchanged.
|
||||
|
||||
The patch includes release creation, publication toggle, separate product upload and read-only public details. Editing existing releases, deletion, detailed per-platform requirements, and automatic client-side updates are not implemented here. Uploaded files are limited to 500 MiB by Laravel validation and additionally by PHP/nginx upload limits. Review your server upload limits.
|
||||
|
||||
Do not apply to production before testing staging, storage persistence, URLs, signatures and permissions. No database data is deleted on rollback.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user