site updates

This commit is contained in:
Mark Gregory
2026-10-06 08:47:30 +01:00
parent f9c697619a
commit 211115c9bc
154 changed files with 2560 additions and 280 deletions
+1
View File
@@ -25,3 +25,4 @@ _ide_helper.php
Homestead.json
Homestead.yaml
Thumbs.db
/.scannerwork/
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\CmsPage;
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) { $page = CmsPage::create($this->validated($request)); 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) { $page->update($this->validated($request, $page)); 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 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,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.');
}
}
@@ -0,0 +1,53 @@
<?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 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',
]);
}
}
+22
View File
@@ -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,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);
}
}
@@ -2,7 +2,7 @@
namespace App\Console\Commands;
use App\Models\License;
use Modules\Licensing\Models\License;
use App\Models\User;
use App\Services\Accounts\PurchasedAccountProvisioner;
use Illuminate\Console\Command;
@@ -3,8 +3,8 @@
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseActivation;
use Modules\Licensing\Models\License;
use Modules\Licensing\Models\LicenseActivation;
use App\Models\User;
use Illuminate\Http\Request;
@@ -27,7 +27,17 @@ final class CmsPageController extends Controller
'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,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.');
}
}
@@ -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);
}
}
@@ -16,7 +16,7 @@ final class AuthenticatedSessionController extends Controller
public function store(Request $request)
{
$credentials = $request->validate(['email' => ['required', 'email'], 'password' => ['required', 'string']]);
if (! Auth::attempt(['email' => strtolower($credentials['email']), 'password' => $credentials['password']], $request->boolean('remember'))) {
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();
@@ -0,0 +1,53 @@
<?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 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',
]);
}
}
+9 -2
View File
@@ -6,10 +6,17 @@ use Illuminate\Database\Eloquent\Model;
final class CmsPage extends Model
{
protected $fillable = ['title', 'slug', 'excerpt', 'body', 'meta_title', 'meta_description', 'is_published', 'published_at'];
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'];
return [
'is_published' => 'boolean', 'published_at' => 'datetime', 'robots_index' => 'boolean',
'robots_follow' => 'boolean', 'include_in_sitemap' => 'boolean', 'sitemap_priority' => 'decimal:1',
];
}
}
@@ -18,6 +18,12 @@ final class SiteSetting extends Model
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]);
+9 -2
View File
@@ -11,12 +11,19 @@ class User extends Authenticatable implements MustVerifyEmail
{
use HasFactory, Notifiable;
protected $fillable = ['name', 'email', 'password', 'is_admin'];
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'];
return ['email_verified_at' => 'datetime', 'password' => 'hashed', 'is_admin' => 'boolean', 'disabled_at' => 'datetime'];
}
}
@@ -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'
);
}
}
@@ -1,26 +0,0 @@
<?php
namespace App\Providers;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
final class SonarDeskLicensingServiceProvider extends ServiceProvider
{
public function boot(): void
{
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'))),
];
});
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()));
}
}
@@ -28,7 +28,6 @@ final class PurchasedAccountProvisioner
* 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.
* enure this is pushed
*/
$user = $this->findByEmail($email);
+1 -3
View File
@@ -1,7 +1,5 @@
<?php
use App\Providers\SonarDeskLicensingServiceProvider;
return [
SonarDeskLicensingServiceProvider::class,
App\Providers\AppServiceProvider::class,
];
+21 -2
View File
@@ -3,13 +3,31 @@
"name": "laravel/laravel",
"type": "project",
"description": "The skeleton application for the Laravel framework.",
"keywords": ["laravel", "framework"],
"keywords": [
"laravel",
"framework"
],
"license": "MIT",
"repositories": [
{
"type": "vcs",
"url": "git@github.com:MPBA-Ltd/laravel_modules.git"
},
{
"type": "vcs",
"url": "git@github.com:MPBA-Ltd/laravel_settings.git"
},
{
"type": "vcs",
"url": "git@github.com:MPBA-Ltd/laravel-charts.git"
}
],
"require": {
"php": "^8.3",
"deployer/deployer": "^8.0",
"laravel/framework": "^13.17",
"laravel/tinker": "^3.0",
"mpba/laravel-modules": "dev-main",
"predis/predis": "^3.6"
},
"require-dev": {
@@ -25,7 +43,8 @@
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
"Database\\Seeders\\": "database/seeders/",
"Modules\\": "modules/"
}
},
"autoload-dev": {
+110 -13
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "1f5b5a47e5910fdf5b2c47e1bc45e1b0",
"content-hash": "d92f21f84e06e667b70468859bfaffd0",
"packages": [
{
"name": "brick/math",
@@ -1542,16 +1542,16 @@
},
{
"name": "league/commonmark",
"version": "2.10.1",
"version": "2.10.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/commonmark.git",
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e"
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/9d489ab67a02960fd8ffe624d93f751daf95439e",
"reference": "9d489ab67a02960fd8ffe624d93f751daf95439e",
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"reference": "6efbd9c472b91db0a3350fcd601c8332c2382e1f",
"shasum": ""
},
"require": {
@@ -1645,7 +1645,7 @@
"type": "tidelift"
}
],
"time": "2026-09-07T13:44:26+00:00"
"time": "2026-09-21T13:07:34+00:00"
},
{
"name": "league/config",
@@ -2256,6 +2256,101 @@
],
"time": "2026-09-02T12:39:56+00:00"
},
{
"name": "mpba/laravel-modules",
"version": "dev-main",
"source": {
"type": "git",
"url": "https://github.com/MPBA-Ltd/laravel_modules.git",
"reference": "55d802560f6404d6b9fd71a89561ed46d32124ff"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MPBA-Ltd/laravel_modules/zipball/55d802560f6404d6b9fd71a89561ed46d32124ff",
"reference": "55d802560f6404d6b9fd71a89561ed46d32124ff",
"shasum": ""
},
"require": {
"ext-json": "*",
"php": ">=7.3"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.16",
"laravel/framework": "^8.0",
"mockery/mockery": "~1.0",
"orchestra/testbench": "^6.0",
"phpstan/phpstan": "^0.12.14",
"phpunit/phpunit": "^8.5",
"spatie/phpunit-snapshot-assertions": "^2.1.0"
},
"default-branch": true,
"type": "library",
"extra": {
"laravel": {
"providers": [
"mpba\\Modules\\LaravelModulesServiceProvider"
],
"aliases": {
"Module": "mpba\\Modules\\Facades\\Module"
}
},
"branch-alias": {
"dev-master": "8.0-dev"
}
},
"autoload": {
"psr-4": {
"mpba\\Modules\\": "src"
},
"files": [
"src/helpers.php"
]
},
"autoload-dev": {
"psr-4": {
"mpba\\Modules\\Tests\\": "tests",
"Modules\\Recipe\\": "tests/stubs/valid/Recipe"
}
},
"scripts": {
"update-snapshots": [
"./vendor/bin/phpunit --no-coverage -d --update-snapshots"
],
"test": [
"vendor/bin/phpunit"
],
"test-coverage": [
"vendor/bin/phpunit --debug --coverage-html coverage"
],
"pcf": [
"vendor/bin/php-cs-fixer fix --verbose"
]
},
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Gregory",
"email": "mark.gregory@mpba.co.uk",
"homepage": "https://mpba.co.uk",
"role": "Developer"
}
],
"description": "Laravel Module management",
"keywords": [
"laravel",
"module",
"modules",
"mpba",
"rad"
],
"support": {
"source": "https://github.com/MPBA-Ltd/laravel_modules/tree/main",
"issues": "https://github.com/MPBA-Ltd/laravel_modules/issues"
},
"time": "2026-09-30T13:50:27+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.13.2",
@@ -4875,16 +4970,16 @@
},
{
"name": "symfony/polyfill-php80",
"version": "v1.37.0",
"version": "v1.43.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
"reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
"url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
"reference": "9c6a5d6b01ca51d486c813e9a9c0ed55f208bb74",
"shasum": ""
},
"require": {
@@ -4935,7 +5030,7 @@
"shim"
],
"support": {
"source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
"source": "https://github.com/symfony/polyfill-php80/tree/v1.43.0"
},
"funding": [
{
@@ -4955,7 +5050,7 @@
"type": "tidelift"
}
],
"time": "2026-04-10T16:19:22+00:00"
"time": "2026-09-25T15:50:05+00:00"
},
{
"name": "symfony/polyfill-php82",
@@ -8709,7 +8804,9 @@
],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"stability-flags": {
"mpba/laravel-modules": 20
},
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
-15
View File
@@ -28,21 +28,6 @@ return [
'notify_admin_users' => filter_var(env('SONARDESK_SUPPORT_NOTIFY_ADMINS', true), FILTER_VALIDATE_BOOL),
],
'paddle' => [
'api_key' => env('PADDLE_API_KEY'),
'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'),
'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'),
'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'),
'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'),
'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300),
// SonarDesk is one Paddle product with four recurring prices.
'product_id' => env('PADDLE_PRODUCT_ID'),
'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'),
'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'),
'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'),
'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'),
],
'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,
]);
}
}
@@ -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',
]);
});
}
};
@@ -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'));
}
}
};
@@ -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');
}
};
+3 -3
View File
@@ -19,13 +19,13 @@ add('writable_dirs', ['storage', 'bootstrap/cache']);
// Host Definitions
host('main')
->set('hostname', 'sonardesk.absdev.net')
->set('hostname', '217.154.47.197')
->set('remote_user', 'mag')
->set('branch', 'main')
->set('deploy_path', '/var/www/sonardesk/main');
->set('deploy_path', '/var/www/sonardesk/prod');
host('stage')
->set('hostname', 'sonardesk.stage.absdev.net')
->set('hostname', '192.168.1.2')
->set('remote_user', 'mag')
->set('branch', 'stage')
->set('deploy_path', '/var/www/sonardesk/stage');
@@ -0,0 +1,8 @@
<?php
return [
'name' => 'Licensing',
'features' =>
[
],
];
@@ -1,8 +1,8 @@
<?php
namespace App\Console\Commands;
namespace Modules\Licensing\Console;
use App\Services\Licensing\LicenseDeliveryService;
use Modules\Licensing\Services\LicenseDeliveryService;
use Illuminate\Console\Command;
final class SonarDeskLicenseDeliver extends Command
@@ -1,9 +1,9 @@
<?php
namespace App\Console\Commands;
namespace Modules\Licensing\Console;
use App\Models\LicenseProduct;
use App\Services\Licensing\LicenseIssuer;
use Modules\Licensing\Models\LicenseProduct;
use Modules\Licensing\Services\LicenseIssuer;
use Illuminate\Console\Command;
class SonarDeskLicenseIssue extends Command
@@ -1,8 +1,8 @@
<?php
namespace App\Console\Commands;
namespace Modules\Licensing\Console;
use App\Models\License;
use Modules\Licensing\Models\License;
use Illuminate\Console\Command;
class SonarDeskLicenseRevoke extends Command
@@ -1,8 +1,8 @@
<?php
namespace App\Console\Commands;
namespace Modules\Licensing\Console;
use App\Models\LicenseProduct;
use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Console\Command;
class SonarDeskProductCreate extends Command
@@ -0,0 +1,92 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('license_products', function (Blueprint $table) {
$table->id();
$table->string('code')->unique();
$table->string('name');
$table->string('edition')->default('Professional');
$table->unsignedInteger('activation_limit')->default(1);
$table->unsignedInteger('max_major_version')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_product_id')->nullable();
$table->string('external_price_id')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
$table->unique(['payment_provider', 'external_price_id']);
});
Schema::create('licenses', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('license_product_id')->constrained('license_products');
$table->string('license_key_hash', 64)->unique();
$table->string('license_key_last4', 4);
$table->string('customer_name')->nullable();
$table->string('customer_email')->nullable()->index();
$table->string('edition');
$table->unsignedInteger('activation_limit');
$table->unsignedInteger('max_major_version')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamp('revoked_at')->nullable();
$table->string('revoke_reason')->nullable();
$table->string('payment_provider')->nullable();
$table->string('external_order_id')->nullable();
$table->string('external_customer_id')->nullable();
$table->timestamps();
$table->unique(['payment_provider', 'external_order_id']);
});
Schema::create('license_delivery_outbox', function (Blueprint $table) {
$table->id();
$table->uuid('license_id')->unique();
$table->text('encrypted_license_key');
$table->string('recipient_email')->nullable();
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
});
Schema::create('license_activations', function (Blueprint $table) {
$table->id();
$table->uuid('license_id');
$table->uuid('installation_id');
$table->string('platform', 32)->nullable();
$table->string('app_version', 32)->nullable();
$table->timestamp('activated_at');
$table->timestamp('last_seen_at')->nullable();
$table->timestamp('deactivated_at')->nullable();
$table->foreign('license_id')->references('id')->on('licenses')->cascadeOnDelete();
$table->unique(['license_id', 'installation_id']);
$table->index(['license_id', 'deactivated_at']);
});
Schema::create('license_webhook_events', function (Blueprint $table) {
$table->id();
$table->string('provider');
$table->string('external_event_id');
$table->string('event_type');
$table->string('payload_sha256', 64);
$table->json('payload');
$table->timestamp('processed_at')->nullable();
$table->text('processing_error')->nullable();
$table->timestamps();
$table->unique(['provider', 'external_event_id']);
});
}
public function down(): void
{
Schema::dropIfExists('license_webhook_events');
Schema::dropIfExists('license_activations');
Schema::dropIfExists('license_delivery_outbox');
Schema::dropIfExists('licenses');
Schema::dropIfExists('license_products');
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('users') && ! Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->boolean('is_admin')->default(false)->index());
}
if (Schema::hasTable('license_activations') && ! Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->string('installation_name')->nullable()->after('installation_id'));
}
}
public function down(): void
{
if (Schema::hasTable('license_activations') && Schema::hasColumn('license_activations', 'installation_name')) {
Schema::table('license_activations', fn (Blueprint $table) => $table->dropColumn('installation_name'));
}
if (Schema::hasTable('users') && Schema::hasColumn('users', 'is_admin')) {
Schema::table('users', fn (Blueprint $table) => $table->dropColumn('is_admin'));
}
}
};
@@ -0,0 +1,21 @@
<?php
namespace Modules\Licensing\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;
class LicensingDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Model::unguard();
// $this->call("OthersTableSeeder");
}
}
@@ -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);
}
}
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Api;
namespace Modules\Licensing\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseActivation;
use App\Services\Licensing\LicenseKey;
use App\Services\Licensing\LicenseSigner;
use App\Services\Licensing\LicenseTokenVerifier;
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;
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Api;
namespace Modules\Licensing\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\LicenseWebhookEvent;
use App\Services\Licensing\LicenseDeliveryService;
use Modules\Licensing\Models\LicenseWebhookEvent;
use Modules\Licensing\Services\LicenseDeliveryService;
use App\Services\Payments\LemonSqueezyWebhookVerifier;
use App\Services\Payments\PaddleWebhookVerifier;
use App\Services\Payments\PurchaseProvisioner;
use Modules\Paddle\Services\PaddleWebhookVerifier;
use Modules\Paddle\Services\PurchaseProvisioner;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -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)
{
//
}
}
@@ -1,13 +1,13 @@
<?php
namespace App\Http\Controllers\Portal;
namespace Modules\Licensing\Http\Controllers\Portal;
use App\Http\Controllers\Controller;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
use App\Services\Payments\PaddleClient;
use App\Services\Licensing\LicenseDeliveryService;
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;
@@ -131,7 +131,7 @@ final class PortalController extends Controller
private function professionalPriceFor(License $license): string
{
$paddle = config('sonardesk.paddle');
$paddle = config('paddle');
$current = (string) $license->external_price_id;
if ($current === (string) ($paddle['personal_annual_price_id'] ?? '')) {
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
@@ -1,6 +1,6 @@
<?php
namespace App\Models;
namespace Modules\Licensing\Models;
use Illuminate\Database\Eloquent\Model;
@@ -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
@@ -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');
});
@@ -1,8 +1,8 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
use App\Models\LicenseDeliveryOutbox;
use Modules\Licensing\Models\LicenseDeliveryOutbox;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Mail;
@@ -1,10 +1,10 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
use App\Models\License;
use App\Models\LicenseDeliveryOutbox;
use App\Models\LicenseProduct;
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;
@@ -1,6 +1,6 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
final class LicenseKey
{
@@ -1,8 +1,8 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
use App\Models\License;
use Modules\Licensing\Models\License;
use Carbon\CarbonInterface;
use RuntimeException;
@@ -1,6 +1,6 @@
<?php
namespace App\Services\Licensing;
namespace Modules\Licensing\Services;
use RuntimeException;
@@ -0,0 +1,26 @@
<?php
namespace Modules\Licensing\Support;
class ModuleInfo
{
protected static string $moduleName = 'Licensing';
/**
* defines the Module Name
* @return string
*/
public static function name(): string
{
return static::$moduleName;
}
/**
* define the lowercase version
* @return string
*/
public static function nameLower(): string
{
return strtolower(static::$moduleName);
}
}
@@ -0,0 +1,15 @@
{
"name": "mpba/licensing",
"description": "SonarDesk Licensing module",
"type": "library",
"autoload": {
"psr-4": {
"Modules\\Licensing\\": ""
}
},
"autoload-dev": {
"psr-4": {
"Modules\\Licensing\\Tests\\": "Tests/"
}
}
}
@@ -0,0 +1,14 @@
{
"name": "Licensing",
"alias": "licensing",
"version": "1.0.0",
"description": "SonarDesk Licensing module",
"keywords": [],
"priority": 0,
"providers": [
"Modules\\Licensing\\Providers\\LicensingServiceProvider"
],
"aliases": {},
"files": [],
"requires": []
}
@@ -0,0 +1,17 @@
{
"private": true,
"scripts": {
"dev": "npm run development",
"development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
"watch-poll": "npm run watch -- --watch-poll",
"hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
"prod": "npm run production",
"production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js"
},
"devDependencies": {
"cross-env": "^7.0",
"laravel-mix": "^5.0.1",
"laravel-mix-merge-manifest": "^0.1.2"
}
}
@@ -0,0 +1,11 @@
const dotenvExpand = require('dotenv-expand');
dotenvExpand(require('dotenv').config({ path: '../../.env'/*, debug: true*/}));
const mix = require('laravel-mix');
mix.js(__dirname + '/Resources/assets/js/app.js', 'js/licensing.js')
.sass( __dirname + '/Resources/assets/sass/app.scss', 'css/licensing.css');
if (mix.inProduction()) {
mix.version();
}
@@ -0,0 +1,16 @@
<?php
return [
'name' => 'Paddle',
'api_key' => env('PADDLE_API_KEY'),
'client_side_token' => env('PADDLE_CLIENT_SIDE_TOKEN'),
'environment' => env('PADDLE_ENVIRONMENT', env('APP_ENV') === 'production' ? 'production' : 'sandbox'),
'api_url' => env('PADDLE_API_URL', env('APP_ENV') === 'production' ? 'https://api.paddle.com' : 'https://sandbox-api.paddle.com'),
'webhook_secret' => env('PADDLE_WEBHOOK_SECRET'),
'timestamp_tolerance' => (int) env('PADDLE_WEBHOOK_TOLERANCE', 300),
'product_id' => env('PADDLE_PRODUCT_ID'),
'personal_monthly_price_id' => env('PADDLE_PERSONAL_MONTHLY_PRICE_ID'),
'personal_annual_price_id' => env('PADDLE_PERSONAL_ANNUAL_PRICE_ID'),
'professional_monthly_price_id' => env('PADDLE_PROFESSIONAL_MONTHLY_PRICE_ID'),
'professional_annual_price_id' => env('PADDLE_PROFESSIONAL_ANNUAL_PRICE_ID'),
];
@@ -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',
]);
});
}
};
@@ -1,8 +1,8 @@
<?php
namespace Database\Seeders;
namespace Modules\Paddle\Database\Seeders;
use App\Models\LicenseProduct;
use Modules\Licensing\Models\LicenseProduct;
use Illuminate\Database\Seeder;
class PaddleCatalogSeeder extends Seeder
@@ -10,10 +10,10 @@ class PaddleCatalogSeeder extends Seeder
public function run(): void
{
$products = [
['code' => 'personal-monthly', 'name' => 'SonarDesk Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.personal_monthly_price_id')],
['code' => 'personal-annual', 'name' => 'SonarDesk Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.personal_annual_price_id')],
['code' => 'professional-monthly', 'name' => 'SonarDesk Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.professional_monthly_price_id')],
['code' => 'professional-annual', 'name' => 'SonarDesk Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('sonardesk.paddle.product_id'), 'external_price_id' => config('sonardesk.paddle.professional_annual_price_id')],
['code' => 'personal-monthly', 'name' => 'SonarDesk Personal Monthly', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.personal_monthly_price_id')],
['code' => 'personal-annual', 'name' => 'SonarDesk Personal Annual', 'edition' => 'Personal', 'activation_limit' => 1, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.personal_annual_price_id')],
['code' => 'professional-monthly', 'name' => 'SonarDesk Professional Monthly', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.professional_monthly_price_id')],
['code' => 'professional-annual', 'name' => 'SonarDesk Professional Annual', 'edition' => 'Professional', 'activation_limit' => 3, 'external_product_id' => config('paddle.product_id'), 'external_price_id' => config('paddle.professional_annual_price_id')],
];
foreach ($products as $data) {
@@ -0,0 +1,21 @@
<?php
namespace Modules\Paddle\Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Database\Eloquent\Model;
class PaddleDatabaseSeeder extends Seeder
{
/**
* Run the database seeds.
*
* @return void
*/
public function run()
{
Model::unguard();
// $this->call("OthersTableSeeder");
}
}
@@ -0,0 +1,12 @@
<?php
use Modules\Paddle\Support\ModuleInfo;
if (! function_exists('paddle_feature_enabled')) {
/**
* Check if a feature is enabled in the config.
*/
function paddle_feature_enabled(string $key): bool
{
return (bool) config(ModuleInfo::nameLower().".features.{$key}", false);
}
}
@@ -1,6 +1,8 @@
<?php
namespace App\Http\Controllers;
namespace Modules\Paddle\Http\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Contracts\View\View;
use RuntimeException;
@@ -9,7 +11,7 @@ final class CheckoutController extends Controller
{
public function __invoke(): View
{
$paddle = config('sonardesk.paddle');
$paddle = config('paddle');
$token = trim((string) ($paddle['client_side_token'] ?? ''));
if ($token === '') {

Some files were not shown because too many files have changed in this diff Show More