move into sonardesk
This commit is contained in:
@@ -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,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Modules\Licensing\Models\License;
|
||||
use App\Models\User;
|
||||
use App\Services\Accounts\PurchasedAccountProvisioner;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class SonarDeskAccountsBackfill extends Command
|
||||
{
|
||||
protected $signature = 'sonardesk:accounts:backfill {--dry-run : Report missing customer accounts without creating them} {--no-email : Create accounts without sending password setup links}';
|
||||
|
||||
protected $description = 'Create portal accounts for existing licence holders that do not already have one.';
|
||||
|
||||
public function handle(PurchasedAccountProvisioner $accounts): int
|
||||
{
|
||||
$customers = License::query()
|
||||
->whereNotNull('customer_email')
|
||||
->where('customer_email', '<>', '')
|
||||
->select(['customer_email', 'customer_name'])
|
||||
->orderBy('customer_email')
|
||||
->get()
|
||||
->unique(fn (License $license) => strtolower(trim((string) $license->customer_email)));
|
||||
|
||||
$missing = 0;
|
||||
$created = 0;
|
||||
|
||||
foreach ($customers as $license) {
|
||||
$email = strtolower(trim((string) $license->customer_email));
|
||||
if ($email === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (User::query()->whereRaw('lower(email) = ?', [$email])->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$missing++;
|
||||
if ($this->option('dry-run')) {
|
||||
$this->line("MISSING {$email}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($accounts->ensure($email, $license->customer_name, ! $this->option('no-email'))) {
|
||||
$created++;
|
||||
$this->info("CREATED {$email}");
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->info("{$missing} account(s) would be created.");
|
||||
} else {
|
||||
$this->info("{$created} account(s) created.");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class SonarDeskAdminCreate extends Command
|
||||
{
|
||||
protected $signature = 'sonardesk:admin {email} {--name=Administrator}';
|
||||
|
||||
protected $description = 'Create or promote an SonarDesk licensing administrator';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$email = strtolower(trim($this->argument('email')));
|
||||
$password = Str::password(24, true, true, false, false);
|
||||
$u = User::firstOrNew(['email' => $email]);
|
||||
$u->name = $this->option('name');
|
||||
$u->is_admin = true;
|
||||
$u->email_verified_at = now();
|
||||
if (! $u->exists) {
|
||||
$u->password = Hash::make($password);
|
||||
} $u->save();
|
||||
$this->info("Administrator ready: {$email}");
|
||||
if (! $u->wasRecentlyCreated) {
|
||||
$this->line('Existing password retained.');
|
||||
} else {
|
||||
$this->warn("Temporary password (shown once): {$password}");
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\CmsPage;
|
||||
use App\Services\IndexNowService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Throwable;
|
||||
|
||||
final class SubmitIndexNow extends Command
|
||||
{
|
||||
protected $signature = 'indexnow:submit {urls?* : Absolute URLs to submit} {--all : Submit all public indexable SonarDesk URLs}';
|
||||
protected $description = 'Submit public SonarDesk URLs to IndexNow';
|
||||
|
||||
public function handle(IndexNowService $indexNow): int
|
||||
{
|
||||
if (! config('indexnow.enabled')) {
|
||||
$this->warn('IndexNow is disabled. Set INDEXNOW_ENABLED=true to enable submissions.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$urls = $this->argument('urls');
|
||||
if ($this->option('all') || $urls === []) {
|
||||
$urls = $this->allPublicUrls();
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $indexNow->submit($urls);
|
||||
} catch (Throwable $e) {
|
||||
$this->error($e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($response === null || ! $response->successful()) {
|
||||
$this->error('IndexNow rejected the submission'.($response ? ' (HTTP '.$response->status().').' : '.'));
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Submitted '.count($urls).' URL(s) to IndexNow (HTTP '.$response->status().').');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/** @return array<int, string> */
|
||||
private function allPublicUrls(): array
|
||||
{
|
||||
$routes = ['home', 'features', 'buy', 'release', 'support', 'privacy', 'terms', 'refund', 'cookies'];
|
||||
$urls = collect($routes)->filter(fn (string $name) => Route::has($name))->map(fn (string $name) => route($name));
|
||||
|
||||
$pages = CmsPage::query()
|
||||
->where('is_published', true)
|
||||
->where('robots_index', true)
|
||||
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
|
||||
->pluck('slug')
|
||||
->map(fn (string $slug) => route('cms.show', ['slug' => $slug]));
|
||||
|
||||
return $urls->merge($pages)->unique()->values()->all();
|
||||
}
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Modules\Licensing\Models\LicenseActivation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class AdminController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.index', ['licenseCount' => License::count(), 'activeCount' => License::whereNull('revoked_at')->count(), 'activationCount' => LicenseActivation::whereNull('deactivated_at')->count(), 'userCount' => User::count()]);
|
||||
}
|
||||
|
||||
public function licenses(Request $r)
|
||||
{
|
||||
$q = License::with('product')->latest();
|
||||
if ($s = trim((string) $r->query('q'))) {
|
||||
$q->where(fn ($x) => $x->where('customer_email', 'like', "%{$s}%")->orWhere('customer_name', 'like', "%{$s}%")->orWhere('license_key_last4', 'like', "%{$s}%"));
|
||||
}
|
||||
|
||||
return view('admin.licenses', ['licenses' => $q->paginate(50)->withQueryString()]);
|
||||
}
|
||||
|
||||
public function show(License $license)
|
||||
{
|
||||
$license->load(['product', 'activations']);
|
||||
|
||||
return view('admin.license', compact('license'));
|
||||
}
|
||||
|
||||
public function revoke(Request $request, License $license)
|
||||
{
|
||||
$data = $request->validate(['reason' => ['nullable', 'string', 'max:255']]);
|
||||
$license->update(['revoked_at' => now(), 'revoke_reason' => $data['reason'] ?: 'Revoked by administrator']);
|
||||
|
||||
return back()->with('status', 'Licence revoked.');
|
||||
}
|
||||
|
||||
public function restore(License $license)
|
||||
{
|
||||
$license->update(['revoked_at' => null, 'revoke_reason' => null]);
|
||||
|
||||
return back()->with('status','Licence restored.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CmsPage;
|
||||
use App\Services\IndexNowService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class CmsPageController extends Controller
|
||||
{
|
||||
public function index() { return view('admin.cms.index', ['pages' => CmsPage::latest()->paginate(25)]); }
|
||||
public function create() { return view('admin.cms.form', ['page' => new CmsPage()]); }
|
||||
public function store(Request $request, IndexNowService $indexNow) { $page = CmsPage::create($this->validated($request)); $this->submitIfIndexable($page, $indexNow); return redirect()->route('admin.cms.edit', $page)->with('status', 'Page created.'); }
|
||||
public function edit(CmsPage $page) { return view('admin.cms.form', compact('page')); }
|
||||
public function update(Request $request, CmsPage $page, IndexNowService $indexNow) { $page->update($this->validated($request, $page)); $this->submitIfIndexable($page, $indexNow); return back()->with('status', 'Page updated.'); }
|
||||
public function destroy(CmsPage $page) { $page->delete(); return redirect()->route('admin.cms.index')->with('status', 'Page deleted.'); }
|
||||
|
||||
private function submitIfIndexable(CmsPage $page, IndexNowService $indexNow): void
|
||||
{
|
||||
if (! $page->is_published || ! $page->robots_index || ($page->published_at && $page->published_at->isFuture())) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$indexNow->submit([route('cms.show', ['slug' => $page->slug])]);
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
|
||||
private function validated(Request $request, ?CmsPage $page = null): array
|
||||
{
|
||||
$request->merge(['slug' => Str::slug($request->input('slug') ?: $request->input('title', ''))]);
|
||||
$data = $request->validate([
|
||||
'title' => ['required','string','max:160'],
|
||||
'slug' => ['required','string','max:180', Rule::unique('cms_pages','slug')->ignore($page?->id)],
|
||||
'excerpt' => ['nullable','string','max:1000'],
|
||||
'body' => ['required','string'],
|
||||
'meta_title' => ['nullable','string','max:160'],
|
||||
'meta_description' => ['nullable','string','max:320'],
|
||||
'meta_keywords' => ['nullable','string','max:500'],
|
||||
'canonical_url' => ['nullable','url:http,https','max:2048'],
|
||||
'og_title' => ['nullable','string','max:160'],
|
||||
'og_description' => ['nullable','string','max:320'],
|
||||
'og_image' => ['nullable','url:http,https','max:2048'],
|
||||
'sitemap_priority' => ['required','numeric','min:0','max:1'],
|
||||
'sitemap_change_frequency' => ['required', Rule::in(['always','hourly','daily','weekly','monthly','yearly','never'])],
|
||||
]);
|
||||
$data['robots_index'] = $request->boolean('robots_index');
|
||||
$data['robots_follow'] = $request->boolean('robots_follow');
|
||||
$data['include_in_sitemap'] = $request->boolean('include_in_sitemap');
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
$data['published_at'] = $data['is_published'] ? ($page?->published_at ?? now()) : null;
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
namespace App\Http\Controllers\Admin;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\DownloadProduct;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
final class DownloadProductController extends Controller {
|
||||
public function index() { return view('admin.download-products.index', ['products'=>DownloadProduct::latest()->get()]); }
|
||||
public function store(Request $request) {
|
||||
$data=$request->validate(['name'=>'required|string|max:150','description'=>'nullable|string|max:12000','version'=>'nullable|string|max:80','platform'=>'nullable|string|max:120','package'=>'required|file|max:1048576','is_published'=>'sometimes|boolean']);
|
||||
$file=$request->file('package');
|
||||
$path=$file->store('download-products','local');
|
||||
DownloadProduct::create(['name'=>$data['name'],'description'=>$data['description']??null,'version'=>$data['version']??null,'platform'=>$data['platform']??null,'file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize(),'is_published'=>$request->boolean('is_published')]);
|
||||
return back()->with('status','Product added.');
|
||||
}
|
||||
public function update(Request $request, DownloadProduct $product) {
|
||||
$data=$request->validate(['name'=>'required|string|max:150','description'=>'nullable|string|max:12000','version'=>'nullable|string|max:80','platform'=>'nullable|string|max:120','package'=>'nullable|file|max:1048576','is_published'=>'sometimes|boolean']);
|
||||
$changes=['name'=>$data['name'],'description'=>$data['description']??null,'version'=>$data['version']??null,'platform'=>$data['platform']??null,'is_published'=>$request->boolean('is_published')];
|
||||
if ($request->hasFile('package')) { $file=$request->file('package'); $path=$file->store('download-products','local'); $old=$product->file_path; $changes+=['file_path'=>$path,'original_filename'=>$file->getClientOriginalName(),'file_size'=>$file->getSize()]; $product->update($changes); if($old) Storage::disk('local')->delete($old); }
|
||||
else $product->update($changes);
|
||||
return back()->with('status','Product updated.');
|
||||
}
|
||||
public function publication(Request $request, DownloadProduct $product) {
|
||||
$data = $request->validate(['is_published' => 'required|boolean']);
|
||||
$product->update(['is_published' => (bool) $data['is_published']]);
|
||||
return back()->with('status', $product->is_published ? 'Product published.' : 'Product unpublished.');
|
||||
}
|
||||
public function destroy(DownloadProduct $product) { if($product->file_path) Storage::disk('local')->delete($product->file_path); $product->delete(); return back()->with('status','Product deleted.'); }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\SonarDeskRelease;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class ReleaseController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
return view('admin.releases.index', [
|
||||
'releases' => SonarDeskRelease::latest()->paginate(30),
|
||||
'publishedCount' => SonarDeskRelease::where('is_published', true)->count(),
|
||||
'downloadCount' => SonarDeskRelease::sum('download_count'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function create() { return view('admin.releases.form', ['release' => new SonarDeskRelease]); }
|
||||
public function edit(SonarDeskRelease $release) { return view('admin.releases.form', compact('release')); }
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
$release = new SonarDeskRelease;
|
||||
$this->persist($request, $release);
|
||||
return redirect()->route('admin.releases.index')->with('status', 'Release created.');
|
||||
}
|
||||
|
||||
public function update(Request $request, SonarDeskRelease $release): RedirectResponse
|
||||
{
|
||||
$this->persist($request, $release);
|
||||
return redirect()->route('admin.releases.index')->with('status', 'Release updated.');
|
||||
}
|
||||
|
||||
public function destroy(SonarDeskRelease $release): RedirectResponse
|
||||
{
|
||||
if ($release->file_path) Storage::disk('local')->delete($release->file_path);
|
||||
$release->delete();
|
||||
return back()->with('status', 'Release deleted.');
|
||||
}
|
||||
|
||||
public function settings()
|
||||
{
|
||||
$keys = ['downloads.eyebrow','downloads.title','downloads.intro','downloads.requirements','downloads.support_text'];
|
||||
$settings = collect($keys)->mapWithKeys(fn ($key) => [$key => SiteSetting::valueFor($key)]);
|
||||
return view('admin.releases.settings', compact('settings'));
|
||||
}
|
||||
|
||||
public function updateSettings(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'eyebrow' => ['required','string','max:80'], 'title' => ['required','string','max:120'],
|
||||
'intro' => ['required','string','max:500'], 'requirements' => ['required','string','max:500'],
|
||||
'support_text' => ['required','string','max:500'],
|
||||
]);
|
||||
foreach ($data as $key => $value) SiteSetting::put('downloads.'.$key, $value);
|
||||
return back()->with('status', 'Download page content updated.');
|
||||
}
|
||||
|
||||
private function persist(Request $request, SonarDeskRelease $release): void
|
||||
{
|
||||
$data = $request->validate([
|
||||
'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','max:20000'], 'sparkle_signature' => ['nullable','string','max:2000'],
|
||||
'package' => [
|
||||
$release->exists ? 'nullable' : 'required',
|
||||
'file',
|
||||
'max:1048576',
|
||||
function (string $attribute, $value, \Closure $fail): void {
|
||||
$extension = strtolower($value->getClientOriginalExtension());
|
||||
if (! in_array($extension, ['dmg', 'zip'], true)) {
|
||||
$fail('The package must be a DMG or ZIP file.');
|
||||
}
|
||||
},
|
||||
],
|
||||
'is_published' => ['nullable','boolean'], 'is_featured' => ['nullable','boolean'],
|
||||
]);
|
||||
$data['is_published'] = $request->boolean('is_published');
|
||||
$data['is_featured'] = $request->boolean('is_featured');
|
||||
if ($data['is_featured']) SonarDeskRelease::query()->update(['is_featured' => false]);
|
||||
|
||||
if ($request->hasFile('package')) {
|
||||
if ($release->file_path) Storage::disk('local')->delete($release->file_path);
|
||||
$file = $request->file('package');
|
||||
$path = $file->store('sonardesk/releases', 'local');
|
||||
$data['file_path'] = $path;
|
||||
$data['original_filename'] = $file->getClientOriginalName();
|
||||
$data['file_size'] = $file->getSize();
|
||||
$data['sha256'] = hash_file('sha256', $file->getRealPath());
|
||||
}
|
||||
unset($data['package']);
|
||||
$data['published_at'] = $data['is_published'] ? ($release->published_at ?: now()) : null;
|
||||
$release->fill($data)->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
final class SeoSettingsController extends Controller
|
||||
{
|
||||
private const KEYS = [
|
||||
'seo_site_name', 'seo_title_suffix', 'seo_default_title', 'seo_default_description',
|
||||
'seo_default_keywords', 'seo_canonical_base_url', 'seo_default_og_image', 'seo_twitter_site',
|
||||
'seo_organization_name', 'seo_organization_logo', 'seo_google_site_verification',
|
||||
];
|
||||
|
||||
public function edit(): View
|
||||
{
|
||||
return view('admin.seo.edit', [
|
||||
'settings' => collect(self::KEYS)->mapWithKeys(fn (string $key) => [$key => SiteSetting::valueFor($key, '')]),
|
||||
'robotsEnabled' => SiteSetting::booleanFor('seo_robots_enabled', true),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'seo_site_name' => ['required', 'string', 'max:80'],
|
||||
'seo_title_suffix' => ['nullable', 'string', 'max:80'],
|
||||
'seo_default_title' => ['required', 'string', 'max:160'],
|
||||
'seo_default_description' => ['required', 'string', 'max:320'],
|
||||
'seo_default_keywords' => ['nullable', 'string', 'max:500'],
|
||||
'seo_canonical_base_url' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_default_og_image' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_twitter_site' => ['nullable', 'string', 'max:40', 'regex:/^@?[A-Za-z0-9_]{1,15}$/'],
|
||||
'seo_organization_name' => ['nullable', 'string', 'max:120'],
|
||||
'seo_organization_logo' => ['nullable', 'url:http,https', 'max:2048'],
|
||||
'seo_google_site_verification' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
|
||||
foreach (self::KEYS as $key) {
|
||||
SiteSetting::put($key, isset($data[$key]) ? trim((string) $data[$key]) : null);
|
||||
}
|
||||
SiteSetting::put('seo_robots_enabled', $request->boolean('seo_robots_enabled') ? '1' : '0');
|
||||
|
||||
return back()->with('status', 'SEO settings updated.');
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SupportTicket;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class SupportTicketController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$tickets = SupportTicket::query()
|
||||
->with('assignee')
|
||||
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
|
||||
->latest()
|
||||
->paginate(50)
|
||||
->withQueryString();
|
||||
|
||||
return view('admin.support.index', compact('tickets'));
|
||||
}
|
||||
|
||||
public function show(SupportTicket $ticket)
|
||||
{
|
||||
$ticket->load(['user', 'assignee']);
|
||||
if (auth()->user()) {
|
||||
auth()->user()->unreadNotifications()->get()
|
||||
->filter(fn ($notification) => (int) ($notification->data['support_ticket_id'] ?? 0) === $ticket->id)
|
||||
->each->markAsRead();
|
||||
}
|
||||
|
||||
return view('admin.support.show', [
|
||||
'ticket' => $ticket,
|
||||
'admins' => User::query()->where('is_admin', true)->orderBy('name')->get(['id', 'name', 'email']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, SupportTicket $ticket): RedirectResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'status' => ['required', 'in:new,open,waiting,resolved,closed'],
|
||||
'priority' => ['required', 'in:low,normal,high,urgent'],
|
||||
'assigned_to' => ['nullable', 'integer', 'exists:users,id'],
|
||||
]);
|
||||
|
||||
if ($data['assigned_to'] ?? null) {
|
||||
abort_unless(User::whereKey($data['assigned_to'])->where('is_admin', true)->exists(), 422);
|
||||
}
|
||||
|
||||
$data['resolved_at'] = in_array($data['status'], ['resolved', 'closed'], true)
|
||||
? ($ticket->resolved_at ?? now())
|
||||
: null;
|
||||
$ticket->update($data);
|
||||
|
||||
return back()->with('status', 'Support ticket updated.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Modules\Licensing\Models\License;
|
||||
use Modules\Licensing\Models\LicenseActivation;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class UserController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = User::query()->latest();
|
||||
|
||||
if ($search = trim((string) $request->query('q'))) {
|
||||
$query->where(fn ($q) => $q
|
||||
->where('name', 'like', "%{$search}%")
|
||||
->orWhere('email', 'like', "%{$search}%"));
|
||||
}
|
||||
|
||||
if ($request->query('status') === 'admin') {
|
||||
$query->where('is_admin', true);
|
||||
} elseif ($request->query('status') === 'suspended') {
|
||||
$query->whereNotNull('disabled_at');
|
||||
} elseif ($request->query('status') === 'unverified') {
|
||||
$query->whereNull('email_verified_at');
|
||||
}
|
||||
|
||||
$users = $query->paginate(50)->withQueryString();
|
||||
$emails = $users->getCollection()->pluck('email')->map(fn ($email) => strtolower($email))->all();
|
||||
$licenseCounts = License::query()
|
||||
->selectRaw('LOWER(customer_email) as email_key, COUNT(*) as aggregate')
|
||||
->whereIn(DB::raw('LOWER(customer_email)'), $emails)
|
||||
->groupBy(DB::raw('LOWER(customer_email)'))
|
||||
->pluck('aggregate', 'email_key');
|
||||
|
||||
return view('admin.users.index', compact('users', 'licenseCounts'));
|
||||
}
|
||||
|
||||
public function show(User $user)
|
||||
{
|
||||
$licenses = License::with(['product', 'activations'])
|
||||
->whereRaw('LOWER(customer_email) = ?', [strtolower($user->email)])
|
||||
->latest()
|
||||
->get();
|
||||
|
||||
return view('admin.users.show', compact('user', 'licenses'));
|
||||
}
|
||||
|
||||
public function update(Request $request, User $user)
|
||||
{
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($user->id)],
|
||||
'is_admin' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$oldEmail = strtolower($user->email);
|
||||
$newEmail = strtolower(trim($data['email']));
|
||||
$isSelf = $request->user()->is($user);
|
||||
|
||||
DB::transaction(function () use ($user, $data, $oldEmail, $newEmail, $isSelf): void {
|
||||
$user->name = trim($data['name']);
|
||||
$user->email = $newEmail;
|
||||
if (! $isSelf) {
|
||||
$user->is_admin = (bool) ($data['is_admin'] ?? false);
|
||||
}
|
||||
$user->save();
|
||||
|
||||
if ($oldEmail !== $newEmail) {
|
||||
License::whereRaw('LOWER(customer_email) = ?', [$oldEmail])
|
||||
->update(['customer_email' => $newEmail]);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->route('admin.users.show', $user)->with('status', 'User updated.');
|
||||
}
|
||||
|
||||
public function verify(User $user)
|
||||
{
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
$user->markEmailAsVerified();
|
||||
}
|
||||
|
||||
return back()->with('status', 'Email marked as verified.');
|
||||
}
|
||||
|
||||
public function resendVerification(User $user)
|
||||
{
|
||||
if (! $user->hasVerifiedEmail()) {
|
||||
$user->sendEmailVerificationNotification();
|
||||
}
|
||||
|
||||
return back()->with('status', 'Verification email sent.');
|
||||
}
|
||||
|
||||
public function sendPasswordReset(User $user)
|
||||
{
|
||||
$status = Password::sendResetLink(['email' => $user->email]);
|
||||
|
||||
return $status === Password::RESET_LINK_SENT
|
||||
? back()->with('status', 'Password reset link sent.')
|
||||
: back()->withErrors(['email' => __($status)]);
|
||||
}
|
||||
|
||||
public function suspend(Request $request, User $user)
|
||||
{
|
||||
abort_if($request->user()->is($user), 422, 'You cannot suspend your own administrator account.');
|
||||
$user->forceFill(['disabled_at' => now(), 'remember_token' => null])->save();
|
||||
|
||||
return back()->with('status', 'User suspended.');
|
||||
}
|
||||
|
||||
public function restore(User $user)
|
||||
{
|
||||
$user->forceFill(['disabled_at' => null])->save();
|
||||
|
||||
return back()->with('status', 'User restored.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, User $user)
|
||||
{
|
||||
abort_if($request->user()->is($user), 422, 'You cannot delete your own administrator account.');
|
||||
$user->delete();
|
||||
|
||||
return redirect()->route('admin.users.index')->with('status', 'User deleted. Licences were retained.');
|
||||
}
|
||||
|
||||
public function updateLicense(Request $request, User $user, License $license)
|
||||
{
|
||||
$this->ensureOwnsLicense($user, $license);
|
||||
$data = $request->validate(['activation_limit' => ['required', 'integer', 'min:1', 'max:1000']]);
|
||||
$license->update(['activation_limit' => $data['activation_limit']]);
|
||||
|
||||
return back()->with('status', 'Activation limit updated.');
|
||||
}
|
||||
|
||||
public function deactivate(User $user, License $license, LicenseActivation $activation)
|
||||
{
|
||||
$this->ensureOwnsLicense($user, $license);
|
||||
abort_unless((string) $activation->license_id === (string) $license->id, 404);
|
||||
$activation->update(['deactivated_at' => now()]);
|
||||
|
||||
return back()->with('status', 'Device activation released.');
|
||||
}
|
||||
|
||||
private function ensureOwnsLicense(User $user, License $license): void
|
||||
{
|
||||
abort_unless(strtolower((string) $license->customer_email) === strtolower($user->email), 404);
|
||||
}
|
||||
}
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
final class AuthenticatedSessionController extends Controller
|
||||
{
|
||||
public function create()
|
||||
{
|
||||
return view('auth.login');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
|
||||
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password'], 'disabled_at' => null], $request->boolean('remember'))) {
|
||||
return back()->withErrors(['email' => 'The supplied credentials are invalid.'])->onlyInput('email');
|
||||
}
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->intended(route('portal.index'));
|
||||
}
|
||||
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login');
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Foundation\Auth\EmailVerificationRequest;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
final class EmailVerificationController extends Controller
|
||||
{
|
||||
public function notice()
|
||||
{
|
||||
return view('auth.verify-email');
|
||||
}
|
||||
|
||||
public function verify(EmailVerificationRequest $request)
|
||||
{
|
||||
$request->fulfill();
|
||||
|
||||
return redirect()->route('portal.index')->with('status', 'Email verified.');
|
||||
}
|
||||
|
||||
public function resend(Request $request)
|
||||
{
|
||||
if (! $request->user()->hasVerifiedEmail()) {
|
||||
$request->user()->sendEmailVerificationNotification();
|
||||
}
|
||||
|
||||
return back()->with('status', 'Verification link sent.');
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password as PasswordRule;
|
||||
|
||||
final class PasswordController extends Controller
|
||||
{
|
||||
public function requestForm()
|
||||
{
|
||||
return view('auth.forgot-password');
|
||||
}
|
||||
|
||||
public function email(Request $request)
|
||||
{
|
||||
$request->validate(['email' => ['required', 'email']]);
|
||||
$status = Password::sendResetLink(['email' => strtolower($request->email)]);
|
||||
|
||||
return $status === Password::RESET_LINK_SENT ? back()->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
|
||||
}
|
||||
|
||||
public function resetForm(Request $request, string $token)
|
||||
{
|
||||
return view('auth.reset-password', ['token' => $token, 'email' => $request->query('email')]);
|
||||
}
|
||||
|
||||
public function reset(Request $request)
|
||||
{
|
||||
$data = $request->validate(['token' => ['required'], 'email' => ['required', 'email'], 'password' => ['required', 'confirmed', PasswordRule::min(12)->letters()->mixedCase()->numbers()]]);
|
||||
$status = Password::reset($data, function (User $user, string $password) {
|
||||
$user->forceFill(['password' => $password, 'remember_token' => Str::random(60)])->save();
|
||||
event(new PasswordReset($user));
|
||||
});
|
||||
|
||||
return $status === Password::PASSWORD_RESET ? redirect()->route('login')->with('status', __($status)) : back()->withErrors(['email' => __($status)]);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
final class RegisteredUserController extends Controller
|
||||
{
|
||||
public function create()
|
||||
{
|
||||
return view('auth.register');
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$data = $request->validate(['name' => ['required', 'string', 'max:120'], 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'confirmed', Password::min(12)->letters()->mixedCase()->numbers()]]);
|
||||
$user = User::create(['name' => $data['name'], 'email' => strtolower($data['email']), 'password' => $data['password']]);
|
||||
event(new Registered($user));
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
|
||||
return redirect()->route('verification.notice');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CmsPage;
|
||||
|
||||
final class CmsPageController extends Controller
|
||||
{
|
||||
public function show(string $slug)
|
||||
{
|
||||
$page = CmsPage::where('slug', $slug)->where('is_published', true)->where(fn ($q) => $q->whereNull('published_at')->orWhere('published_at', '<=', now()))->firstOrFail();
|
||||
return view('cms.show', compact('page'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
// Application controller base class.
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\SiteSetting;
|
||||
use App\Models\DownloadProduct;
|
||||
use App\Models\SonarDeskRelease;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
final class ReleaseController extends Controller
|
||||
{
|
||||
public function index()
|
||||
{
|
||||
$latest = SonarDeskRelease::published()->where('is_featured', true)->orderByDesc('published_at')->first()
|
||||
?? SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->orderByDesc('id')->first();
|
||||
$releases = SonarDeskRelease::published()
|
||||
->when($latest, fn ($query) => $query->whereKeyNot($latest->getKey()))
|
||||
->orderByDesc('published_at')->paginate(12);
|
||||
$settings = $this->settings();
|
||||
$products = DownloadProduct::where('is_published', true)->whereNotNull('file_path')
|
||||
->when($latest, fn ($query) => $query->whereRaw('LOWER(name) <> ?', ['sonardesk']))
|
||||
->orderBy('name')->get();
|
||||
|
||||
return view('downloads.index', compact('latest', 'releases', 'settings', 'products'));
|
||||
}
|
||||
|
||||
public function sonarDeskDetails()
|
||||
{
|
||||
$latest = SonarDeskRelease::published()->where('is_featured', true)->orderByDesc('published_at')->first()
|
||||
?? SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->orderByDesc('id')->firstOrFail();
|
||||
$settings = $this->settings();
|
||||
return view('downloads.sonardesk', compact('latest', 'settings'));
|
||||
}
|
||||
|
||||
public function latest(): BinaryFileResponse
|
||||
{
|
||||
$release = SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->firstOrFail();
|
||||
return $this->send($release);
|
||||
}
|
||||
|
||||
public function download(SonarDeskRelease $release): BinaryFileResponse
|
||||
{
|
||||
abort_unless($release->is_published && $release->published_at && $release->file_path, 404);
|
||||
return $this->send($release);
|
||||
}
|
||||
|
||||
public function productDetails(DownloadProduct $product)
|
||||
{
|
||||
abort_unless($product->is_published && $product->file_path, 404);
|
||||
return view('downloads.product', compact('product'));
|
||||
}
|
||||
|
||||
public function productDownload(DownloadProduct $product): BinaryFileResponse
|
||||
{
|
||||
abort_unless($product->is_published && $product->file_path && Storage::disk('local')->exists($product->file_path), 404);
|
||||
$product->increment('download_count');
|
||||
return response()->download(Storage::disk('local')->path($product->file_path), $product->original_filename);
|
||||
}
|
||||
|
||||
public function appcast(): Response
|
||||
{
|
||||
$releases = SonarDeskRelease::published()->where('channel', 'stable')->orderByDesc('published_at')->limit(20)->get();
|
||||
return response()->view('downloads.appcast', compact('releases'), 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
|
||||
}
|
||||
|
||||
private function send(SonarDeskRelease $release): BinaryFileResponse
|
||||
{
|
||||
abort_unless(Storage::disk('local')->exists($release->file_path), 404);
|
||||
$release->increment('download_count');
|
||||
return response()->download(Storage::disk('local')->path($release->file_path), $release->original_filename ?: 'SonarDesk-'.$release->version.'.dmg');
|
||||
}
|
||||
|
||||
private function settings(): array
|
||||
{
|
||||
return [
|
||||
'eyebrow' => SiteSetting::valueFor('downloads.eyebrow', 'SonarDesk for macOS'),
|
||||
'title' => SiteSetting::valueFor('downloads.title', 'Download SonarDesk'),
|
||||
'intro' => SiteSetting::valueFor('downloads.intro', 'A focused native SonarQube workspace, designed for macOS and built for daily engineering work.'),
|
||||
'requirements' => SiteSetting::valueFor('downloads.requirements', 'Apple silicon and Intel Macs supported. A SonarQube server and valid SonarDesk licence are required.'),
|
||||
'support_text' => SiteSetting::valueFor('downloads.support_text', 'Need help installing or activating SonarDesk? Our support team can help.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\CmsPage;
|
||||
use App\Models\SiteSetting;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
final class SeoController extends Controller
|
||||
{
|
||||
public function sitemap(): Response
|
||||
{
|
||||
$static = collect([
|
||||
['route' => 'home', 'priority' => '1.0', 'frequency' => 'weekly'],
|
||||
['route' => 'features', 'priority' => '0.9', 'frequency' => 'monthly'],
|
||||
['route' => 'buy', 'priority' => '0.9', 'frequency' => 'monthly'],
|
||||
['route' => 'release', 'priority' => '0.8', 'frequency' => 'weekly'],
|
||||
['route' => 'support', 'priority' => '0.6', 'frequency' => 'monthly'],
|
||||
['route' => 'privacy', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'terms', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'refund', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
['route' => 'cookies', 'priority' => '0.3', 'frequency' => 'yearly'],
|
||||
])->filter(fn (array $item) => Route::has($item['route']))->values();
|
||||
|
||||
$pages = CmsPage::query()
|
||||
->where('is_published', true)
|
||||
->where('include_in_sitemap', true)
|
||||
->where('robots_index', true)
|
||||
->where(fn ($query) => $query->whereNull('published_at')->orWhere('published_at', '<=', now()))
|
||||
->orderBy('slug')
|
||||
->get();
|
||||
|
||||
return response()
|
||||
->view('seo.sitemap', compact('static', 'pages'))
|
||||
->header('Content-Type', 'application/xml; charset=UTF-8')
|
||||
->header('Cache-Control', 'public, max-age=3600');
|
||||
}
|
||||
|
||||
public function indexNowKey(string $key): Response
|
||||
{
|
||||
$configured = trim((string) config('indexnow.key'));
|
||||
|
||||
abort_unless(config('indexnow.enabled') && $configured !== '' && hash_equals($configured, $key), 404);
|
||||
|
||||
return response($configured, 200, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
}
|
||||
|
||||
public function robots(): Response
|
||||
{
|
||||
$indexable = app()->environment('production') && SiteSetting::booleanFor('seo_robots_enabled', true);
|
||||
|
||||
$body = $indexable
|
||||
? "User-agent: *\nAllow: /\nDisallow: /admin/\nDisallow: /portal/\nDisallow: /login\nDisallow: /register\n\nSitemap: ".route('sitemap')."\n"
|
||||
: "User-agent: *\nDisallow: /\n";
|
||||
|
||||
return response($body, 200, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
}
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Support;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SupportTicket;
|
||||
use App\Models\User;
|
||||
use App\Notifications\NewSupportTicket;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class SupportController extends Controller
|
||||
{
|
||||
public function create(Request $request)
|
||||
{
|
||||
return view('support', [
|
||||
'categories' => [
|
||||
'technical' => 'Technical support',
|
||||
'licensing' => 'Licence & activation',
|
||||
'billing' => 'Billing & subscription',
|
||||
'feature' => 'Feature request',
|
||||
'privacy' => 'Privacy / data request',
|
||||
'general' => 'General enquiry',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request): RedirectResponse
|
||||
{
|
||||
// Honeypot: bots tend to fill hidden fields. Return success without retaining spam.
|
||||
if (filled($request->input('company_website'))) {
|
||||
return back()->with('status', 'Your support request has been received.');
|
||||
}
|
||||
|
||||
$data = $request->validate([
|
||||
'name' => ['required', 'string', 'max:120'],
|
||||
'email' => ['required', 'email:rfc', 'max:254'],
|
||||
'category' => ['required', 'in:technical,licensing,billing,feature,privacy,general'],
|
||||
'subject' => ['required', 'string', 'max:180'],
|
||||
'message' => ['required', 'string', 'min:10', 'max:10000'],
|
||||
'privacy_notice' => ['accepted'],
|
||||
]);
|
||||
|
||||
$ticket = SupportTicket::create([
|
||||
'user_id' => $request->user()?->id,
|
||||
'reference' => $this->reference(),
|
||||
'name' => $data['name'],
|
||||
'email' => strtolower($data['email']),
|
||||
'category' => $data['category'],
|
||||
'subject' => $data['subject'],
|
||||
'message' => $data['message'],
|
||||
'privacy_notice_accepted_at' => now(),
|
||||
]);
|
||||
|
||||
$this->notifyTeam($ticket);
|
||||
|
||||
Mail::raw(
|
||||
"We received your SonarDesk support request.\n\nReference: {$ticket->reference}\nSubject: {$ticket->subject}\n\nWe will reply to this email address. Please keep the reference in any follow-up.",
|
||||
fn ($mail) => $mail->to($ticket->email)->subject("SonarDesk support request {$ticket->reference}")
|
||||
);
|
||||
|
||||
return redirect()->route('support')->with('status', "Support request {$ticket->reference} received. A confirmation has been emailed to you.");
|
||||
}
|
||||
|
||||
private function notifyTeam(SupportTicket $ticket): void
|
||||
{
|
||||
if (config('sonardesk.support.notify_admin_users', true)) {
|
||||
User::query()->where('is_admin', true)->get()->each->notify(new NewSupportTicket($ticket));
|
||||
}
|
||||
|
||||
$emails = collect(config('sonardesk.support.alert_emails', []))
|
||||
->filter(fn ($email) => filter_var($email, FILTER_VALIDATE_EMAIL))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($emails->isNotEmpty()) {
|
||||
Notification::route('mail', $emails->all())->notify(new NewSupportTicket($ticket));
|
||||
}
|
||||
}
|
||||
|
||||
private function reference(): string
|
||||
{
|
||||
do {
|
||||
$reference = 'RS-'.now()->format('ymd').'-'.Str::upper(Str::random(6));
|
||||
} while (SupportTicket::where('reference', $reference)->exists());
|
||||
|
||||
return $reference;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
final class AdminOnly
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
abort_unless($request->user()?->is_admin === true, 403);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class CmsPage extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'meta_keywords',
|
||||
'canonical_url', 'og_title', 'og_description', 'og_image', 'robots_index', 'robots_follow',
|
||||
'include_in_sitemap', 'sitemap_priority', 'sitemap_change_frequency', 'is_published', 'published_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
|
||||
'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
namespace App\Models;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
final class DownloadProduct extends Model {
|
||||
protected $fillable = ['name','description','version','platform','file_path','original_filename','file_size','download_count','is_published'];
|
||||
protected function casts(): array { return ['is_published'=>'boolean','file_size'=>'integer','download_count'=>'integer']; }
|
||||
public function getFormattedSizeAttribute(): string { return $this->file_size >= 1048576 ? number_format($this->file_size/1048576,1).' MB' : number_format($this->file_size/1024,1).' KB'; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class SiteSetting extends Model
|
||||
{
|
||||
protected $table = 'sonardesk_site_settings';
|
||||
protected $primaryKey = 'key';
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
protected $fillable = ['key', 'value'];
|
||||
|
||||
public static function valueFor(string $key, ?string $default = null): ?string
|
||||
{
|
||||
return Cache::remember('sonardesk.setting.'.$key, 300, fn () => static::query()->find($key)?->value ?? $default);
|
||||
}
|
||||
|
||||
public static function booleanFor(string $key, bool $default = false): bool
|
||||
{
|
||||
$value = static::valueFor($key);
|
||||
return $value === null ? $default : filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
public static function put(string $key, ?string $value): void
|
||||
{
|
||||
static::query()->updateOrCreate(['key' => $key], ['value' => $value]);
|
||||
Cache::forget('sonardesk.setting.'.$key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\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', 'download_count',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'is_published' => 'boolean',
|
||||
'is_featured' => 'boolean',
|
||||
'published_at' => 'datetime',
|
||||
'file_size' => 'integer',
|
||||
'download_count' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
public function scopePublished($query)
|
||||
{
|
||||
return $query->where('is_published', true)->whereNotNull('file_path')->whereNotNull('published_at');
|
||||
}
|
||||
|
||||
public function getFormattedSizeAttribute(): string
|
||||
{
|
||||
$bytes = (int) $this->file_size;
|
||||
if ($bytes < 1024) return $bytes.' B';
|
||||
if ($bytes < 1048576) return number_format($bytes / 1024, 1).' KB';
|
||||
if ($bytes < 1073741824) return number_format($bytes / 1048576, 1).' MB';
|
||||
return number_format($bytes / 1073741824, 2).' GB';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Prunable;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class SupportTicket extends Model
|
||||
{
|
||||
use Prunable;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id', 'assigned_to', 'reference', 'name', 'email', 'category',
|
||||
'subject', 'message', 'status', 'priority', 'privacy_notice_accepted_at',
|
||||
'resolved_at',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'privacy_notice_accepted_at' => 'datetime',
|
||||
'resolved_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function assignee(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_to');
|
||||
}
|
||||
|
||||
public function prunable(): Builder
|
||||
{
|
||||
$days = max(30, (int) config('sonardesk.privacy.support_retention_days', 730));
|
||||
|
||||
return static::query()
|
||||
->whereNotNull('resolved_at')
|
||||
->where('resolved_at', '<=', now()->subDays($days));
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class User extends Authenticatable implements MustVerifyEmail
|
||||
{
|
||||
use HasFactory, Notifiable;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'email',
|
||||
'email_verified_at',
|
||||
'password',
|
||||
'is_admin',
|
||||
'disabled_at',
|
||||
];
|
||||
|
||||
protected $hidden = ['password', 'remember_token'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean', 'disabled_at' => 'datetime'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\SupportTicket;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class NewSupportTicket extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public readonly SupportTicket $ticket) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return $notifiable instanceof AnonymousNotifiable ? ['mail'] : ['database', 'mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject("New SonarDesk support request {$this->ticket->reference}")
|
||||
->greeting('New support request')
|
||||
->line("{$this->ticket->name} <{$this->ticket->email}>")
|
||||
->line("Category: {$this->ticket->category}")
|
||||
->line("Subject: {$this->ticket->subject}")
|
||||
->line($this->ticket->message)
|
||||
->action('Open support ticket', route('admin.support.show', $this->ticket));
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'support_ticket_id' => $this->ticket->id,
|
||||
'reference' => $this->ticket->reference,
|
||||
'subject' => $this->ticket->subject,
|
||||
'email' => $this->ticket->email,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
Blade::anonymousComponentPath(
|
||||
resource_path('views/components/layouts'),
|
||||
'layouts'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Accounts;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
final class PurchasedAccountProvisioner
|
||||
{
|
||||
public function ensure(
|
||||
?string $email,
|
||||
?string $name = null,
|
||||
bool $sendSetupLink = true
|
||||
): ?User {
|
||||
$email = strtolower(trim((string) $email));
|
||||
|
||||
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Fast path for the normal case.
|
||||
*
|
||||
* Paddle can deliver several related webhook events concurrently, so
|
||||
* this lookup alone cannot guarantee that another request will not
|
||||
* create the user between this SELECT and our INSERT.
|
||||
*/
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$created = false;
|
||||
|
||||
try {
|
||||
$user = User::create([
|
||||
'name' => $this->displayName($name, $email),
|
||||
'email' => $email,
|
||||
'email_verified_at' => now(),
|
||||
'password' => Str::random(64),
|
||||
'is_admin' => false,
|
||||
]);
|
||||
|
||||
$created = true;
|
||||
} catch (UniqueConstraintViolationException $exception) {
|
||||
/*
|
||||
* Another webhook may have created this account after our SELECT
|
||||
* but before our INSERT. In that case the unique email constraint
|
||||
* correctly rejects our INSERT; simply reuse the account that won
|
||||
* the race.
|
||||
*/
|
||||
$user = $this->findByEmail($email);
|
||||
|
||||
if (! $user) {
|
||||
/*
|
||||
* The unique violation was not caused by the email account
|
||||
* appearing concurrently, so preserve the original failure.
|
||||
*/
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Only the request that actually created the account should send the
|
||||
* account setup/reset link. Concurrent Paddle events must not result
|
||||
* in several setup emails being sent to the customer.
|
||||
*/
|
||||
if ($created && $sendSetupLink) {
|
||||
DB::afterCommit(static function () use ($email): void {
|
||||
Password::sendResetLink(['email' => $email]);
|
||||
});
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
private function findByEmail(string $email): ?User
|
||||
{
|
||||
return User::query()
|
||||
->whereRaw('LOWER(email) = ?', [$email])
|
||||
->first();
|
||||
}
|
||||
|
||||
private function displayName(?string $name, string $email): string
|
||||
{
|
||||
$name = trim((string) $name);
|
||||
|
||||
if ($name !== '') {
|
||||
return mb_substr($name, 0, 120);
|
||||
}
|
||||
|
||||
$localPart = strstr($email, '@', true) ?: 'SonarDesk Customer';
|
||||
$localPart = trim(str_replace(['.', '_', '-'], ' ', $localPart));
|
||||
|
||||
return mb_substr(
|
||||
$localPart !== ''
|
||||
? ucwords($localPart)
|
||||
: 'SonarDesk Customer',
|
||||
0,
|
||||
120
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class IndexNowService
|
||||
{
|
||||
/** @param array<int, string> $urls */
|
||||
public function submit(array $urls): ?Response
|
||||
{
|
||||
if (! config('indexnow.enabled')) {
|
||||
Log::info('IndexNow submission skipped: disabled.');
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = trim((string) config('indexnow.key'));
|
||||
if ($key === '') {
|
||||
throw new InvalidArgumentException('INDEXNOW_KEY is not configured.');
|
||||
}
|
||||
|
||||
$urls = collect($urls)
|
||||
->filter(fn ($url) => is_string($url) && filter_var($url, FILTER_VALIDATE_URL))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($urls === []) {
|
||||
throw new InvalidArgumentException('No valid URLs were supplied to IndexNow.');
|
||||
}
|
||||
|
||||
$host = parse_url($urls[0], PHP_URL_HOST);
|
||||
if (! is_string($host) || $host === '') {
|
||||
throw new InvalidArgumentException('Could not determine the IndexNow host.');
|
||||
}
|
||||
|
||||
foreach ($urls as $url) {
|
||||
if (parse_url($url, PHP_URL_HOST) !== $host) {
|
||||
throw new InvalidArgumentException('All IndexNow URLs must use the same host.');
|
||||
}
|
||||
}
|
||||
|
||||
$response = Http::asJson()
|
||||
->acceptJson()
|
||||
->timeout(10)
|
||||
->retry(2, 500)
|
||||
->post((string) config('indexnow.endpoint'), [
|
||||
'host' => $host,
|
||||
'key' => $key,
|
||||
'keyLocation' => 'https://'.$host.'/'.$key.'.txt',
|
||||
'urlList' => $urls,
|
||||
]);
|
||||
|
||||
if ($response->successful()) {
|
||||
Log::info('IndexNow URLs submitted.', ['host' => $host, 'count' => count($urls), 'status' => $response->status()]);
|
||||
} else {
|
||||
Log::warning('IndexNow submission failed.', ['host' => $host, 'count' => count($urls), 'status' => $response->status(), 'body' => $response->body()]);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
final class CustomerAccountProvisioner
|
||||
{
|
||||
public function ensure(?string $email, ?string $name = null): ?User
|
||||
{
|
||||
$email = strtolower(trim((string) $email));
|
||||
if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$user = User::whereRaw('lower(email) = ?', [$email])->first();
|
||||
if ($user) {
|
||||
return $user;
|
||||
}
|
||||
|
||||
$displayName = trim((string) $name);
|
||||
if ($displayName === '') {
|
||||
$displayName = Str::headline(Str::before($email, '@')) ?: 'SonarDesk Customer';
|
||||
}
|
||||
|
||||
$user = User::firstOrCreate(
|
||||
['email' => $email],
|
||||
[
|
||||
'name' => $displayName,
|
||||
// The customer never receives or knows this password. They choose their
|
||||
// own password through Laravel's signed, single-use reset flow.
|
||||
'password' => Str::random(64),
|
||||
// Access still requires possession of the mailbox because account setup
|
||||
// is completed using the reset link sent to this address.
|
||||
'email_verified_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
if ($user->wasRecentlyCreated) {
|
||||
DB::afterCommit(function () use ($email): void {
|
||||
try {
|
||||
$status = Password::sendResetLink(['email' => $email]);
|
||||
if ($status !== Password::RESET_LINK_SENT) {
|
||||
Log::warning('SonarDesk customer account created but setup email was not sent.', [
|
||||
'email' => $email,
|
||||
'status' => $status,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
// A mail outage must never undo a paid licence. The customer can
|
||||
// also use Forgot Password once mail is restored.
|
||||
report($exception);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use RuntimeException;
|
||||
|
||||
final class LemonSqueezyWebhookVerifier implements WebhookVerifier
|
||||
{
|
||||
public function verify(Request $request, string $rawBody): void
|
||||
{
|
||||
$secret = (string) config('sonardesk.lemon_squeezy.webhook_secret');
|
||||
$signature = (string) $request->header('X-Signature');
|
||||
if ($secret === '' || $signature === '') {
|
||||
throw new RuntimeException('Lemon Squeezy webhook verification is not configured.');
|
||||
}
|
||||
$expected = hash_hmac('sha256', $rawBody, $secret);
|
||||
if (! hash_equals($expected, $signature)) {
|
||||
throw new RuntimeException('Lemon Squeezy webhook signature is invalid.');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Payments;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
interface WebhookVerifier
|
||||
{
|
||||
public function verify(Request $request, string $rawBody): void;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enabled' => (bool) env('INDEXNOW_ENABLED', false),
|
||||
'key' => env('INDEXNOW_KEY'),
|
||||
'endpoint' => env('INDEXNOW_ENDPOINT', 'https://api.indexnow.org/indexnow'),
|
||||
];
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'environment' => env('SONARDESK_ENVIRONMENT', 'develop'),
|
||||
'product_code' => env('SONARDESK_PRODUCT_CODE', 'sonardesk'),
|
||||
'private_key_path' => env('SONARDESK_LICENSE_PRIVATE_KEY_PATH'),
|
||||
'public_key' => env('SONARDESK_LICENSE_PUBLIC_KEY'),
|
||||
'default_edition' => env('SONARDESK_DEFAULT_EDITION', 'Professional'),
|
||||
'default_max_major_version' => (int) env('SONARDESK_DEFAULT_MAX_MAJOR_VERSION', 1),
|
||||
'activation_rate_limit' => env('SONARDESK_ACTIVATION_RATE_LIMIT', '12,1'),
|
||||
'purchase_url' => env('SONARDESK_PURCHASE_URL'),
|
||||
'portal_url' => env('SONARDESK_PORTAL_URL'),
|
||||
'support_url' => env('SONARDESK_SUPPORT_URL'),
|
||||
|
||||
'legal' => [
|
||||
'controller_name' => env('SONARDESK_LEGAL_NAME', 'SonarDesk'),
|
||||
'privacy_email' => env('SONARDESK_PRIVACY_EMAIL', env('MAIL_FROM_ADDRESS')),
|
||||
'support_email' => env('SONARDESK_SUPPORT_EMAIL', env('MAIL_FROM_ADDRESS')),
|
||||
'country' => env('SONARDESK_LEGAL_COUNTRY', 'United Kingdom'),
|
||||
],
|
||||
'privacy' => [
|
||||
'policy_version' => env('SONARDESK_PRIVACY_VERSION', '2026-09-09'),
|
||||
'cookie_version' => env('SONARDESK_COOKIE_VERSION', '1'),
|
||||
'support_retention_days' => (int) env('SONARDESK_SUPPORT_RETENTION_DAYS', 730),
|
||||
],
|
||||
'support' => [
|
||||
'alert_emails' => array_values(array_filter(array_map('trim', explode(',', (string) env('SONARDESK_SUPPORT_ALERT_EMAILS', ''))))),
|
||||
'notify_admin_users' => filter_var(env('SONARDESK_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL),
|
||||
],
|
||||
|
||||
|
||||
'lemon_squeezy' => [
|
||||
'webhook_secret' => env('LEMON_SQUEEZY_WEBHOOK_SECRET'),
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<\App\Models\User>
|
||||
*/
|
||||
class UserFactory extends Factory
|
||||
{
|
||||
protected static ?string $password;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => fake()->name(),
|
||||
'email' => fake()->unique()->safeEmail(),
|
||||
'email_verified_at' => now(),
|
||||
'password' => static::$password ??= Hash::make('password'),
|
||||
'remember_token' => Str::random(10),
|
||||
'is_admin' => false,
|
||||
];
|
||||
}
|
||||
|
||||
public function unverified(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'email_verified_at' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('sessions', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->foreignId('user_id')->nullable()->index();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->text('user_agent')->nullable();
|
||||
$table->longText('payload');
|
||||
$table->integer('last_activity')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sessions');
|
||||
}
|
||||
};
|
||||
+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');
|
||||
}
|
||||
};
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('cache', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->mediumText('value');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
|
||||
Schema::create('cache_locks', function (Blueprint $table) {
|
||||
$table->string('key')->primary();
|
||||
$table->string('owner');
|
||||
$table->bigInteger('expiration')->index();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('cache');
|
||||
Schema::dropIfExists('cache_locks');
|
||||
}
|
||||
};
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
<?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::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('email')->unique();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password');
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('password_reset_tokens')) {
|
||||
Schema::create('password_reset_tokens', function (Blueprint $table): void {
|
||||
$table->string('email')->primary();
|
||||
$table->string('token');
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('password_reset_tokens');
|
||||
Schema::dropIfExists('users');
|
||||
}
|
||||
};
|
||||
Executable
+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'));
|
||||
}
|
||||
}
|
||||
};
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?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::table('licenses', function (Blueprint $table) {
|
||||
$table->string('external_subscription_id')->nullable()->after('external_customer_id');
|
||||
$table->string('external_price_id')->nullable()->after('external_subscription_id');
|
||||
$table->string('subscription_status')->nullable()->after('external_price_id');
|
||||
$table->timestamp('current_period_ends_at')->nullable()->after('subscription_status');
|
||||
$table->timestamp('cancel_scheduled_at')->nullable()->after('current_period_ends_at');
|
||||
$table->unique(['payment_provider', 'external_subscription_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('licenses', function (Blueprint $table) {
|
||||
$table->dropUnique(['payment_provider', 'external_subscription_id']);
|
||||
$table->dropColumn([
|
||||
'external_subscription_id',
|
||||
'external_price_id',
|
||||
'subscription_status',
|
||||
'current_period_ends_at',
|
||||
'cancel_scheduled_at',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?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('support_tickets')) {
|
||||
Schema::create('support_tickets', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('assigned_to')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('reference', 24)->unique();
|
||||
$table->string('name', 120);
|
||||
$table->string('email', 254)->index();
|
||||
$table->string('category', 40)->default('general');
|
||||
$table->string('subject', 180);
|
||||
$table->text('message');
|
||||
$table->string('status', 20)->default('new')->index();
|
||||
$table->string('priority', 20)->default('normal');
|
||||
$table->timestamp('privacy_notice_accepted_at');
|
||||
$table->timestamp('resolved_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('notifications')) {
|
||||
Schema::create('notifications', function (Blueprint $table): void {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('type');
|
||||
$table->morphs('notifiable');
|
||||
$table->text('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('support_tickets');
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?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('cms_pages', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('title');
|
||||
$table->string('slug')->unique();
|
||||
$table->text('excerpt')->nullable();
|
||||
$table->longText('body');
|
||||
$table->string('meta_title')->nullable();
|
||||
$table->string('meta_description', 320)->nullable();
|
||||
$table->boolean('is_published')->default(false)->index();
|
||||
$table->timestamp('published_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void { Schema::dropIfExists('cms_pages'); }
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?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('sonardesk_releases', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('version', 40);
|
||||
$table->unsignedInteger('build_number')->default(1);
|
||||
$table->string('channel', 20)->default('stable');
|
||||
$table->string('minimum_macos', 30)->nullable();
|
||||
$table->string('file_path')->nullable();
|
||||
$table->string('original_filename')->nullable();
|
||||
$table->unsignedBigInteger('file_size')->nullable();
|
||||
$table->string('sha256', 64)->nullable();
|
||||
$table->text('sparkle_signature')->nullable();
|
||||
$table->text('release_notes')->nullable();
|
||||
$table->boolean('is_published')->default(false)->index();
|
||||
$table->boolean('is_featured')->default(false)->index();
|
||||
$table->timestamp('published_at')->nullable()->index();
|
||||
$table->unsignedBigInteger('download_count')->default(0);
|
||||
$table->timestamps();
|
||||
$table->unique(['version', 'build_number', 'channel']);
|
||||
});
|
||||
|
||||
Schema::create('sonardesk_site_settings', function (Blueprint $table): void {
|
||||
$table->string('key')->primary();
|
||||
$table->text('value')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('sonardesk_site_settings');
|
||||
Schema::dropIfExists('sonardesk_releases');
|
||||
}
|
||||
};
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?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::table('cms_pages', function (Blueprint $table): void {
|
||||
$table->string('meta_keywords', 500)->nullable()->after('meta_description');
|
||||
$table->string('canonical_url', 2048)->nullable()->after('meta_keywords');
|
||||
$table->string('og_title')->nullable()->after('canonical_url');
|
||||
$table->string('og_description', 320)->nullable()->after('og_title');
|
||||
$table->string('og_image', 2048)->nullable()->after('og_description');
|
||||
$table->boolean('robots_index')->default(true)->after('og_image');
|
||||
$table->boolean('robots_follow')->default(true)->after('robots_index');
|
||||
$table->boolean('include_in_sitemap')->default(true)->after('robots_follow')->index();
|
||||
$table->decimal('sitemap_priority', 2, 1)->default(0.5)->after('include_in_sitemap');
|
||||
$table->string('sitemap_change_frequency', 20)->default('monthly')->after('sitemap_priority');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('cms_pages', function (Blueprint $table): void {
|
||||
$table->dropIndex(['include_in_sitemap']);
|
||||
$table->dropColumn([
|
||||
'meta_keywords', 'canonical_url', 'og_title', 'og_description', 'og_image',
|
||||
'robots_index', 'robots_follow', 'include_in_sitemap', 'sitemap_priority',
|
||||
'sitemap_change_frequency',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?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', 'disabled_at')) {
|
||||
Schema::table('users', fn (Blueprint $table) => $table->timestamp('disabled_at')->nullable()->index()->after('is_admin'));
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasTable('users') && Schema::hasColumn('users', 'disabled_at')) {
|
||||
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('disabled_at'));
|
||||
}
|
||||
}
|
||||
};
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?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('failed_jobs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->text('connection');
|
||||
$table->text('queue');
|
||||
$table->longText('payload');
|
||||
$table->longText('exception');
|
||||
$table->timestamp('failed_at')->useCurrent();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('failed_jobs');
|
||||
}
|
||||
};
|
||||
+18
@@ -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 {
|
||||
Schema::create('download_products', function (Blueprint $table) {
|
||||
$table->id(); $table->string('name'); $table->text('description')->nullable();
|
||||
$table->string('version')->nullable(); $table->string('platform')->nullable();
|
||||
$table->string('file_path')->nullable(); $table->string('original_filename')->nullable();
|
||||
$table->unsignedBigInteger('file_size')->default(0); $table->unsignedBigInteger('download_count')->default(0);
|
||||
$table->boolean('is_published')->default(false); $table->timestamps();
|
||||
});
|
||||
}
|
||||
public function down(): void { Schema::dropIfExists('download_products'); }
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+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'));
|
||||
}
|
||||
}
|
||||
};
|
||||
+21
@@ -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);
|
||||
}
|
||||
}
|
||||
Executable
+164
@@ -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) []);
|
||||
}
|
||||
}
|
||||
Executable
+191
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
+79
@@ -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)
|
||||
{
|
||||
//
|
||||
}
|
||||
}
|
||||
Executable
+143
@@ -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,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);
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
+171
@@ -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,9 @@
|
||||
@extends('licensing::layouts.master')
|
||||
|
||||
@section('content')
|
||||
<h1>Hello World</h1>
|
||||
|
||||
<p>
|
||||
This view is loaded from module: {!! config('licensing.name') !!}
|
||||
</p>
|
||||
@endsection
|
||||
+19
@@ -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,12 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Licensing\Http\Controllers\Api\LicenseActivationController;
|
||||
use Modules\Licensing\Http\Controllers\Api\PaymentWebhookController;
|
||||
|
||||
Route::prefix('v1')->group(function (): void {
|
||||
Route::post('/activate', [LicenseActivationController::class, 'activate'])->middleware('throttle:license-activation');
|
||||
Route::post('/validate', [LicenseActivationController::class, 'validateInstallation'])->middleware('throttle:license-activation');
|
||||
Route::post('/deactivate', [LicenseActivationController::class, 'deactivate'])->middleware('throttle:license-activation');
|
||||
Route::post('/webhooks/{provider}', PaymentWebhookController::class)->whereIn('provider', ['paddle', 'lemon-squeezy']);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Modules\Licensing\Http\Controllers\Portal\PortalController;
|
||||
|
||||
Route::middleware(['auth', 'verified'])->prefix('portal')->name('portal.')->group(function (): void {
|
||||
Route::get('/', [PortalController::class, 'index'])->name('index');
|
||||
Route::get('/licences/{license}', [PortalController::class, 'show'])->name('show');
|
||||
Route::post('/licences/{license}/upgrade', [PortalController::class, 'upgrade'])->middleware('throttle:5,1')->name('upgrade');
|
||||
Route::delete('/licences/{license}/subscription', [PortalController::class, 'cancel'])->middleware('throttle:5,1')->name('cancel');
|
||||
Route::post('/licences/{license}/resend', [PortalController::class, 'resend'])->middleware('throttle:3,10')->name('resend');
|
||||
Route::delete('/licences/{license}/activations/{activation}', [PortalController::class, 'deactivate'])->name('deactivate');
|
||||
});
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Licensing\Services;
|
||||
|
||||
use Modules\Licensing\Models\LicenseDeliveryOutbox;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
final class LicenseDeliveryService
|
||||
{
|
||||
public function deliverPending(int $limit = 20): int
|
||||
{
|
||||
$delivered = 0;
|
||||
$pending = LicenseDeliveryOutbox::query()
|
||||
->with('license')
|
||||
->whereNull('delivered_at')
|
||||
->whereNotNull('recipient_email')
|
||||
->where('recipient_email', '!=', '')
|
||||
->oldest()
|
||||
->limit(max(1, min($limit, 100)))
|
||||
->get();
|
||||
|
||||
foreach ($pending as $outbox) {
|
||||
$license = $outbox->license;
|
||||
if (! $license) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$plain = Crypt::decryptString($outbox->encrypted_license_key);
|
||||
$recipient = strtolower(trim((string) $outbox->recipient_email));
|
||||
if ($recipient === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$data = [
|
||||
'customerName' => $license->customer_name,
|
||||
'edition' => $license->edition,
|
||||
'licenseKey' => $plain,
|
||||
'expiresAt' => $license->expires_at,
|
||||
'portalUrl' => config('sonardesk.portal_url') ?: url('/portal'),
|
||||
'downloadUrl' => route('release'),
|
||||
];
|
||||
|
||||
Mail::send(['html' => 'emails.license-key', 'text' => 'emails.license-key-text'], $data, function ($message) use ($recipient): void {
|
||||
$message->to($recipient)->subject('Your SonarDesk licence key');
|
||||
});
|
||||
|
||||
$outbox->update(['delivered_at' => now()]);
|
||||
$delivered++;
|
||||
}
|
||||
|
||||
return $delivered;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Licensing\Services;
|
||||
|
||||
use Modules\Licensing\Models\License;
|
||||
use Carbon\CarbonInterface;
|
||||
use RuntimeException;
|
||||
|
||||
final class LicenseSigner
|
||||
{
|
||||
public function sign(License $license, string $installationID, ?CarbonInterface $issuedAt = null): string
|
||||
{
|
||||
$seed = $this->privateSeed();
|
||||
$pair = sodium_crypto_sign_seed_keypair($seed);
|
||||
$secret = sodium_crypto_sign_secretkey($pair);
|
||||
|
||||
$payload = [
|
||||
'customerEmail' => $license->customer_email,
|
||||
'customerName' => $license->customer_name,
|
||||
'edition' => $license->edition,
|
||||
'expiresAt' => $license->expires_at?->utc()->format('Y-m-d\\TH:i:s\\Z'),
|
||||
'installationID' => strtolower($installationID),
|
||||
'issuedAt' => ($issuedAt ?? now())->utc()->format('Y-m-d\\TH:i:s\\Z'),
|
||||
'licenseID' => strtolower((string) $license->id),
|
||||
'maxMajorVersion' => $license->max_major_version,
|
||||
];
|
||||
ksort($payload, SORT_STRING);
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||
$sig = sodium_crypto_sign_detached($json, $secret);
|
||||
|
||||
return 'RS3.'.self::b64url($json).'.'.self::b64url($sig);
|
||||
}
|
||||
|
||||
public function publicKeyBase64(): string
|
||||
{
|
||||
$pair = sodium_crypto_sign_seed_keypair($this->privateSeed());
|
||||
|
||||
return base64_encode(sodium_crypto_sign_publickey($pair));
|
||||
}
|
||||
|
||||
private function privateSeed(): string
|
||||
{
|
||||
$path = config('sonardesk.private_key_path');
|
||||
if (! $path || ! is_file($path)) {
|
||||
throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
|
||||
}
|
||||
$decoded = base64_decode(trim((string) file_get_contents($path)), true);
|
||||
if ($decoded === false || strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
|
||||
throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.');
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private static function b64url(string $value): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Licensing\Services;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class LicenseTokenVerifier
|
||||
{
|
||||
public function verify(string $token): array
|
||||
{
|
||||
$parts = explode('.', trim($token));
|
||||
if (count($parts) !== 3 || $parts[0] !== 'RS3') {
|
||||
throw new RuntimeException('Invalid licence token.');
|
||||
}
|
||||
$payload = $this->decode($parts[1]);
|
||||
$signature = $this->decode($parts[2]);
|
||||
// Derive the verification key from the same private seed used by LicenseSigner.
|
||||
// Keeping a separate SONARDESK_LICENSE_PUBLIC_KEY allowed the two keys to drift:
|
||||
// activation would sign a perfectly valid token with the new private key, then
|
||||
// /validate would reject that same token using a stale public key.
|
||||
$public = $this->publicKeyFromPrivateSeed();
|
||||
if (! sodium_crypto_sign_verify_detached($signature, $payload, $public)) {
|
||||
throw new RuntimeException('Licence token signature is invalid.');
|
||||
}
|
||||
$decoded = json_decode($payload, true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
return is_array($decoded) ? $decoded : throw new RuntimeException('Licence token payload is invalid.');
|
||||
}
|
||||
|
||||
private function publicKeyFromPrivateSeed(): string
|
||||
{
|
||||
$path = config('sonardesk.private_key_path');
|
||||
if (! $path || ! is_file($path)) {
|
||||
throw new RuntimeException('SONARDESK_LICENSE_PRIVATE_KEY_PATH is not configured or does not exist.');
|
||||
}
|
||||
|
||||
$seed = base64_decode(trim((string) file_get_contents($path)), true);
|
||||
if ($seed === false || strlen($seed) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) {
|
||||
throw new RuntimeException('The SonarDesk private key must be a Base64-encoded 32-byte Ed25519 seed.');
|
||||
}
|
||||
|
||||
$pair = sodium_crypto_sign_seed_keypair($seed);
|
||||
|
||||
return sodium_crypto_sign_publickey($pair);
|
||||
}
|
||||
|
||||
private function decode(string $value): string
|
||||
{
|
||||
$value = strtr($value, '-_', '+/');
|
||||
$value .= str_repeat('=', (4 - strlen($value) % 4) % 4);
|
||||
$out = base64_decode($value, true);
|
||||
if ($out === false) {
|
||||
throw new RuntimeException('Invalid Base64URL value.');
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Licensing\Support;
|
||||
|
||||
class ModuleInfo
|
||||
{
|
||||
protected static string $moduleName = 'Licensing';
|
||||
|
||||
/**
|
||||
* defines the Module Name
|
||||
* @return string
|
||||
*/
|
||||
public static function name(): string
|
||||
{
|
||||
return static::$moduleName;
|
||||
}
|
||||
|
||||
/**
|
||||
* define the lowercase version
|
||||
* @return string
|
||||
*/
|
||||
public static function nameLower(): string
|
||||
{
|
||||
return strtolower(static::$moduleName);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user