move into sonardesk

This commit is contained in:
Mark Gregory
2026-10-12 14:50:13 +01:00
parent 5f0d2ceb72
commit 38b4a7406f
879 changed files with 24021 additions and 1 deletions
+14
View File
@@ -0,0 +1,14 @@
<?php
return [
'enabled' => (bool) env('MARKETPLACE_ENABLED', true),
'catalogue_url' => env('MARKETPLACE_CATALOGUE_URL'),
'allow_remote_install' => (bool) env('MARKETPLACE_ALLOW_REMOTE_INSTALL', false),
'require_checksum' => (bool) env('MARKETPLACE_REQUIRE_CHECKSUM', true),
'require_signature' => (bool) env('MARKETPLACE_REQUIRE_SIGNATURE', false),
'trusted_publishers' => array_values(array_filter(array_map('trim', explode(',', (string) env('MARKETPLACE_TRUSTED_PUBLISHERS', ''))))),
'storage_disk' => env('MARKETPLACE_STORAGE_DISK', 'local'),
'package_path' => env('MARKETPLACE_PACKAGE_PATH', 'marketplace/packages'),
'snapshot_path' => env('MARKETPLACE_SNAPSHOT_PATH', 'marketplace/snapshots'),
'supported_types' => ['module', 'theme', 'extension'],
];
@@ -0,0 +1,24 @@
<?php
namespace Modules\Marketplace\Console\Commands;
use Illuminate\Console\Command;
use Modules\Marketplace\Models\InstalledPackage;
final class MarketplaceAuditCommand extends Command
{
protected $signature = 'marketplace:audit';
protected $description = 'Audit installed marketplace package state.';
public function handle(): int
{
$packages = InstalledPackage::query()->with('package')->get();
foreach ($packages as $package) {
$this->line(sprintf('%s %s [%s]%s', $package->package_name, $package->version, $package->status, $package->hasUpdate() ? ' update available' : ''));
}
$this->info($packages->count().' installed package(s) audited.');
return self::SUCCESS;
}
}
@@ -0,0 +1,24 @@
<?php
namespace Modules\Marketplace\Console\Commands;
use Illuminate\Console\Command;
final class MarketplaceSyncCommand extends Command
{
protected $signature = 'marketplace:sync';
protected $description = 'Synchronise the configured marketplace catalogue.';
public function handle(): int
{
if (blank(config('marketplace.catalogue_url'))) {
$this->warn('MARKETPLACE_CATALOGUE_URL is not configured.');
return self::SUCCESS;
}
$this->info('Catalogue synchronisation is configured. Remote installs remain disabled unless explicitly enabled.');
return self::SUCCESS;
}
}
@@ -0,0 +1,96 @@
<?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('marketplace_publishers', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('email')->nullable();
$table->string('website')->nullable();
$table->text('public_key')->nullable();
$table->boolean('is_verified')->default(false)->index();
$table->json('metadata')->nullable();
$table->timestamps();
});
Schema::create('marketplace_packages', function (Blueprint $table) {
$table->id();
$table->foreignId('publisher_id')->nullable()->constrained('marketplace_publishers')->nullOnDelete();
$table->string('type', 30)->index();
$table->string('name');
$table->string('slug')->unique();
$table->string('package_name')->unique();
$table->text('description')->nullable();
$table->string('latest_version')->nullable();
$table->string('homepage')->nullable();
$table->string('repository_url')->nullable();
$table->string('download_url')->nullable();
$table->string('checksum', 128)->nullable();
$table->text('signature')->nullable();
$table->json('manifest')->nullable();
$table->boolean('is_featured')->default(false)->index();
$table->boolean('is_active')->default(true)->index();
$table->timestamp('catalogue_updated_at')->nullable();
$table->timestamps();
});
Schema::create('marketplace_installed_packages', function (Blueprint $table) {
$table->id();
$table->foreignId('marketplace_package_id')->nullable()->constrained('marketplace_packages')->nullOnDelete();
$table->string('package_name')->unique();
$table->string('type', 30)->index();
$table->string('version');
$table->string('status', 30)->default('installed')->index();
$table->json('manifest')->nullable();
$table->string('checksum', 128)->nullable();
$table->string('install_path')->nullable();
$table->boolean('is_enabled')->default(true)->index();
$table->foreignId('installed_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('installed_at')->nullable();
$table->timestamp('updated_at_marketplace')->nullable();
$table->timestamps();
});
Schema::create('marketplace_installations', function (Blueprint $table) {
$table->id();
$table->foreignId('installed_package_id')->nullable()->constrained('marketplace_installed_packages')->nullOnDelete();
$table->string('package_name')->index();
$table->string('operation', 30)->index();
$table->string('from_version')->nullable();
$table->string('to_version')->nullable();
$table->string('status', 30)->default('pending')->index();
$table->string('snapshot_path')->nullable();
$table->text('message')->nullable();
$table->json('context')->nullable();
$table->foreignId('performed_by')->nullable()->constrained('users')->nullOnDelete();
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
});
Schema::create('marketplace_licences', function (Blueprint $table) {
$table->id();
$table->foreignId('marketplace_package_id')->nullable()->constrained('marketplace_packages')->nullOnDelete();
$table->string('licence_key')->nullable();
$table->string('status', 30)->default('inactive')->index();
$table->string('plan')->nullable();
$table->string('licensed_to')->nullable();
$table->timestamp('activated_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->json('metadata')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('marketplace_licences');
Schema::dropIfExists('marketplace_installations');
Schema::dropIfExists('marketplace_installed_packages');
Schema::dropIfExists('marketplace_packages');
Schema::dropIfExists('marketplace_publishers');
}
};
@@ -0,0 +1,40 @@
<?php
namespace Modules\Marketplace\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\Marketplace\Models\InstalledPackage;
use Modules\Marketplace\Models\MarketplacePackage;
use Modules\Marketplace\Services\MarketplaceManager;
final class MarketplaceApiController extends Controller
{
public function packages(Request $request): JsonResponse
{
return response()->json(MarketplacePackage::query()->with('publisher')->where('is_active', true)->when($request->filled('type'), fn ($q) => $q->where('type', $request->string('type')))->paginate(min(100, max(1, $request->integer('per_page', 25)))));
}
public function installed(): JsonResponse
{
return response()->json(InstalledPackage::query()->with('package')->paginate(50));
}
public function install(MarketplacePackage $package, MarketplaceManager $manager, Request $request): JsonResponse
{
return response()->json($manager->install($package, $request->user()?->id)->load('package'), 201);
}
public function toggle(InstalledPackage $package, MarketplaceManager $manager, Request $request): JsonResponse
{
return response()->json($manager->setEnabled($package, ! $package->is_enabled, $request->user()?->id));
}
public function remove(InstalledPackage $package, MarketplaceManager $manager, Request $request): JsonResponse
{
$manager->remove($package, $request->user()?->id);
return response()->json(['status' => 'removed']);
}
}
@@ -0,0 +1,64 @@
<?php
namespace Modules\Marketplace\Http\Controllers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\View\View;
use Modules\Marketplace\Models\Installation;
use Modules\Marketplace\Models\InstalledPackage;
use Modules\Marketplace\Models\MarketplacePackage;
use Modules\Marketplace\Services\MarketplaceManager;
final class MarketplaceController extends Controller
{
public function index(Request $request): View
{
$packages = MarketplacePackage::query()->with('publisher')->where('is_active', true)->when($request->filled('type'), fn ($q) => $q->where('type', $request->string('type')))->when($request->filled('search'), fn ($q) => $q->where(fn ($q) => $q->where('name', 'like', '%'.$request->string('search').'%')->orWhere('description', 'like', '%'.$request->string('search').'%')))->latest('is_featured')->paginate(20)->withQueryString();
return view('marketplace::index', compact('packages'));
}
public function installed(): View
{
$packages = InstalledPackage::query()->with('package')->latest()->paginate(25);
return view('marketplace::installed', compact('packages'));
}
public function install(MarketplacePackage $package, MarketplaceManager $manager, Request $request): RedirectResponse
{
$manager->install($package, $request->user()?->id);
return back()->with('status', 'Package installed successfully.');
}
public function update(MarketplacePackage $package, MarketplaceManager $manager, Request $request): RedirectResponse
{
$manager->install($package, $request->user()?->id);
return back()->with('status', 'Package updated successfully.');
}
public function toggle(InstalledPackage $package, MarketplaceManager $manager, Request $request): RedirectResponse
{
$manager->setEnabled($package, ! $package->is_enabled, $request->user()?->id);
return back()->with('status', 'Package state updated.');
}
public function remove(InstalledPackage $package, MarketplaceManager $manager, Request $request): RedirectResponse
{
$manager->remove($package, $request->user()?->id);
return back()->with('status', 'Package removed.');
}
public function rollback(InstalledPackage $package, Installation $installation, MarketplaceManager $manager, Request $request): RedirectResponse
{
$manager->rollback($package, $installation, $request->user()?->id);
return back()->with('status','Package rolled back.');
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Modules\Marketplace\Models;
use Illuminate\Database\Eloquent\Model;
final class Installation extends Model
{
protected $table = 'marketplace_installations';
protected $fillable = ['installed_package_id', 'package_name', 'operation', 'from_version', 'to_version', 'status', 'snapshot_path', 'message', 'context', 'performed_by', 'started_at', 'completed_at'];
protected $casts = ['context' => 'array', 'started_at' => 'datetime', 'completed_at' => 'datetime'];
}
+31
View File
@@ -0,0 +1,31 @@
<?php
namespace Modules\Marketplace\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class InstalledPackage extends Model
{
protected $table = 'marketplace_installed_packages';
protected $fillable = ['marketplace_package_id', 'package_name', 'type', 'version', 'status', 'manifest', 'checksum', 'install_path', 'is_enabled', 'installed_by', 'installed_at', 'updated_at_marketplace'];
protected $casts = ['manifest' => 'array', 'is_enabled' => 'boolean', 'installed_at' => 'datetime', 'updated_at_marketplace' => 'datetime'];
public function package(): BelongsTo
{
return $this->belongsTo(MarketplacePackage::class, 'marketplace_package_id');
}
public function installations(): HasMany
{
return $this->hasMany(Installation::class, 'installed_package_id');
}
public function hasUpdate(): bool
{
return $this->package?->latest_version && version_compare($this->package->latest_version, $this->version, '>');
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace Modules\Marketplace\Models;
use Illuminate\Database\Eloquent\Model;
final class MarketplaceLicence extends Model
{
protected $table = 'marketplace_licences';
protected $fillable = ['marketplace_package_id', 'licence_key', 'status', 'plan', 'licensed_to', 'activated_at', 'expires_at', 'metadata'];
protected $hidden = ['licence_key'];
protected $casts = ['activated_at' => 'datetime', 'expires_at' => 'datetime', 'metadata' => 'array'];
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace Modules\Marketplace\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class MarketplacePackage extends Model
{
protected $table = 'marketplace_packages';
protected $fillable = ['publisher_id', 'type', 'name', 'slug', 'package_name', 'description', 'latest_version', 'homepage', 'repository_url', 'download_url', 'checksum', 'signature', 'manifest', 'is_featured', 'is_active', 'catalogue_updated_at'];
protected $casts = ['manifest' => 'array', 'is_featured' => 'boolean', 'is_active' => 'boolean', 'catalogue_updated_at' => 'datetime'];
public function publisher(): BelongsTo
{
return $this->belongsTo(Publisher::class);
}
public function licences(): HasMany
{
return $this->hasMany(MarketplaceLicence::class, 'marketplace_package_id');
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace Modules\Marketplace\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
final class Publisher extends Model
{
protected $table = 'marketplace_publishers';
protected $fillable = ['name', 'slug', 'email', 'website', 'public_key', 'is_verified', 'metadata'];
protected $casts = ['is_verified' => 'boolean', 'metadata' => 'array'];
public function packages(): HasMany
{
return $this->hasMany(MarketplacePackage::class, 'publisher_id');
}
}
@@ -0,0 +1,36 @@
<?php
namespace Modules\Marketplace\Providers;
use Illuminate\Support\ServiceProvider;
use Modules\Marketplace\Console\Commands\MarketplaceAuditCommand;
use Modules\Marketplace\Console\Commands\MarketplaceSyncCommand;
use Modules\Marketplace\Services\CompatibilityChecker;
use Modules\Marketplace\Services\DependencyResolver;
use Modules\Marketplace\Services\MarketplaceManager;
use Modules\Marketplace\Services\PackageManifest;
use Modules\Marketplace\Services\PackageVerifier;
final class MarketplaceServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->mergeConfigFrom(module_path('Marketplace', 'Config/config.php'), 'marketplace');
$this->app->singleton(PackageManifest::class);
$this->app->singleton(CompatibilityChecker::class);
$this->app->singleton(DependencyResolver::class);
$this->app->singleton(PackageVerifier::class);
$this->app->singleton(MarketplaceManager::class);
$this->app->register(RouteServiceProvider::class);
}
public function boot(): void
{
$this->loadMigrationsFrom(module_path('Marketplace', 'Database/Migrations'));
$this->loadViewsFrom(module_path('Marketplace', 'Resources/views'), 'marketplace');
$this->loadTranslationsFrom(module_path('Marketplace', 'Resources/lang'), 'marketplace');
if ($this->app->runningInConsole()) {
$this->commands([MarketplaceSyncCommand::class, MarketplaceAuditCommand::class]);
}
}
}
@@ -0,0 +1,15 @@
<?php
namespace Modules\Marketplace\Providers;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Route;
final class RouteServiceProvider extends ServiceProvider
{
public function boot(): void
{
Route::middleware('web')->group(module_path('Marketplace', 'Routes/web.php'));
Route::middleware('api')->prefix('api/v1')->group(module_path('Marketplace', 'Routes/api.php'));
}
}
@@ -0,0 +1,97 @@
<x-layouts.app :title="__('Marketplace')">
<div class="flex h-full w-full flex-1 flex-col gap-6 rounded-xl">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<flux:heading size="xl">Marketplace</flux:heading>
<flux:subheading>
Discover verified modules, themes and extensions.
</flux:subheading>
</div>
<flux:button
:href="route('administration.marketplace.installed')"
wire:navigate
>
Installed packages
</flux:button>
</div>
<form method="GET" class="grid gap-3 md:grid-cols-[1fr_220px_auto]">
<flux:input
name="search"
value="{{ request('search') }}"
placeholder="Search packages"
/>
<flux:select name="type">
<option value="">All types</option>
@foreach (config('marketplace.supported_types', []) as $type)
<option
value="{{ $type }}"
{{ request('type') === $type ? 'selected' : '' }}
>
{{ \Illuminate\Support\Str::headline($type) }}
</option>
@endforeach
</flux:select>
<flux:button type="submit">Filter</flux:button>
</form>
<div class="grid gap-4 lg:grid-cols-2">
@forelse ($packages as $package)
<article class="rounded-xl border p-5">
<div class="flex justify-between gap-4">
<div>
<div class="flex items-center gap-2">
<h2 class="font-semibold">{{ $package->name }}</h2>
@if ($package->is_featured)
<flux:badge color="amber">Featured</flux:badge>
@endif
</div>
<p class="mt-1 text-sm text-zinc-500">
{{ $package->package_name }}
· {{ \Illuminate\Support\Str::headline($package->type) }}
· {{ $package->latest_version ?: 'No release' }}
</p>
</div>
@can('marketplace.install')
<form
method="POST"
action="{{ route('administration.marketplace.install', $package) }}"
>
@csrf
<flux:button type="submit" size="sm" variant="primary">
Install
</flux:button>
</form>
@endcan
</div>
<p class="mt-4 text-sm">{{ $package->description }}</p>
@if ($package->publisher)
<p class="mt-3 text-xs text-zinc-500">
Published by {{ $package->publisher->name }}
@if ($package->publisher->is_verified)
· Verified
@endif
</p>
@endif
</article>
@empty
<div class="col-span-full rounded-xl border p-8 text-center text-zinc-500">
No marketplace packages found.
</div>
@endforelse
</div>
{{ $packages->links() }}
</div>
</x-layouts.app>
@@ -0,0 +1,104 @@
<x-layouts.app :title="__('Installed Packages')">
<div class="flex h-full w-full flex-1 flex-col gap-6 rounded-xl">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<flux:heading size="xl">Installed Packages</flux:heading>
<flux:subheading>
Manage package state, updates and removals.
</flux:subheading>
</div>
<flux:button
:href="route('administration.marketplace.index')"
wire:navigate
>
Browse marketplace
</flux:button>
</div>
<div class="overflow-hidden rounded-xl border">
<table class="w-full text-sm">
<thead>
<tr class="border-b text-left">
<th class="p-3">Package</th>
<th>Version</th>
<th>Status</th>
<th>Update</th>
<th class="p-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
@forelse ($packages as $package)
<tr class="border-b">
<td class="p-3">
<div class="font-medium">{{ $package->package_name }}</div>
<div class="text-xs text-zinc-500">
{{ \Illuminate\Support\Str::headline($package->type) }}
</div>
</td>
<td>{{ $package->version }}</td>
<td>{{ ucfirst($package->status) }}</td>
<td>
{{ $package->hasUpdate() ? $package->package->latest_version : 'Current' }}
</td>
<td class="p-3">
<div class="flex justify-end gap-2">
@can('marketplace.update')
<form
method="POST"
action="{{ route('administration.marketplace.toggle', $package) }}"
>
@csrf
<flux:button type="submit" size="sm">
{{ $package->is_enabled ? 'Disable' : 'Enable' }}
</flux:button>
</form>
@if ($package->hasUpdate())
<form
method="POST"
action="{{ route('administration.marketplace.update', $package->package) }}"
>
@csrf
<flux:button type="submit" size="sm" variant="primary">
Update
</flux:button>
</form>
@endif
@endcan
@can('marketplace.remove')
<form
method="POST"
action="{{ route('administration.marketplace.remove', $package) }}"
>
@csrf
@method('DELETE')
<flux:button type="submit" size="sm" variant="danger">
Remove
</flux:button>
</form>
@endcan
</div>
</td>
</tr>
@empty
<tr>
<td colspan="5" class="p-8 text-center text-zinc-500">
No packages installed through the marketplace.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
{{ $packages->links() }}
</div>
</x-layouts.app>
+12
View File
@@ -0,0 +1,12 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Marketplace\Http\Controllers\MarketplaceApiController;
Route::middleware('auth:sanctum')->prefix('marketplace')->name('api.marketplace.')->group(function () {
Route::get('/packages', [MarketplaceApiController::class, 'packages'])->middleware('permission:marketplace.view')->name('packages');
Route::get('/installed', [MarketplaceApiController::class, 'installed'])->middleware('permission:marketplace.view')->name('installed');
Route::post('/packages/{package}/install', [MarketplaceApiController::class, 'install'])->middleware('permission:marketplace.install')->name('install');
Route::post('/installed/{package}/toggle', [MarketplaceApiController::class, 'toggle'])->middleware('permission:marketplace.update')->name('toggle');
Route::delete('/installed/{package}', [MarketplaceApiController::class, 'remove'])->middleware('permission:marketplace.remove')->name('remove');
});
+14
View File
@@ -0,0 +1,14 @@
<?php
use Illuminate\Support\Facades\Route;
use Modules\Marketplace\Http\Controllers\MarketplaceController;
Route::middleware(['auth', 'verified'])->prefix('administration/marketplace')->name('administration.marketplace.')->group(function () {
Route::get('/', [MarketplaceController::class, 'index'])->middleware('permission:marketplace.view')->name('index');
Route::get('/installed', [MarketplaceController::class, 'installed'])->middleware('permission:marketplace.view')->name('installed');
Route::post('/packages/{package}/install', [MarketplaceController::class, 'install'])->middleware('permission:marketplace.install')->name('install');
Route::post('/packages/{package}/update', [MarketplaceController::class, 'update'])->middleware('permission:marketplace.update')->name('update');
Route::post('/installed/{package}/toggle', [MarketplaceController::class, 'toggle'])->middleware('permission:marketplace.update')->name('toggle');
Route::delete('/installed/{package}', [MarketplaceController::class, 'remove'])->middleware('permission:marketplace.remove')->name('remove');
Route::post('/installed/{package}/rollback/{installation}', [MarketplaceController::class, 'rollback'])->middleware('permission:marketplace.update')->name('rollback');
});
@@ -0,0 +1,30 @@
<?php
namespace Modules\Marketplace\Services;
use Illuminate\Support\Facades\App;
final class CompatibilityChecker
{
public function __construct(private VersionConstraint $constraints = new VersionConstraint) {}
public function check(array $manifest): array
{
$errors = [];
$php = (string) ($manifest['php'] ?? '*');
$laravel = (string) ($manifest['laravel'] ?? '*');
$platform = (string) ($manifest['content_manager'] ?? '*');
$platformVersion = (string) config('app.version', '1.0.0');
if (! $this->constraints->matches(PHP_VERSION, $php)) {
$errors[] = "Requires PHP $php; running ".PHP_VERSION.'.';
}
if (! $this->constraints->matches(App::version(), $laravel)) {
$errors[] = "Requires Laravel $laravel; running ".App::version().'.';
}
if (! $this->constraints->matches($platformVersion, $platform)) {
$errors[] = "Requires Content Manager $platform; running $platformVersion.";
}
return ['compatible' => $errors === [], 'errors' => $errors];
}
}
@@ -0,0 +1,29 @@
<?php
namespace Modules\Marketplace\Services;
use Modules\Marketplace\Models\InstalledPackage;
final class DependencyResolver
{
public function __construct(private VersionConstraint $constraints = new VersionConstraint) {}
public function resolve(array $manifest): array
{
$missing = [];
$incompatible = [];
foreach (($manifest['requires'] ?? []) as $package => $constraint) {
$installed = InstalledPackage::query()->where('package_name', $package)->where('status', 'installed')->first();
if (! $installed) {
$missing[$package] = $constraint;
continue;
}
if (! $this->constraints->matches($installed->version, (string) $constraint)) {
$incompatible[$package] = ['required' => $constraint, 'installed' => $installed->version];
}
}
return ['satisfied' => $missing === [] && $incompatible === [], 'missing' => $missing, 'incompatible' => $incompatible];
}
}
@@ -0,0 +1,72 @@
<?php
namespace Modules\Marketplace\Services;
use Illuminate\Support\Facades\DB;
use Modules\Marketplace\Models\Installation;
use Modules\Marketplace\Models\InstalledPackage;
use Modules\Marketplace\Models\MarketplacePackage;
use RuntimeException;
final class MarketplaceManager
{
public function __construct(private PackageManifest $manifests, private CompatibilityChecker $compatibility, private DependencyResolver $dependencies) {}
public function install(MarketplacePackage $package, ?int $userId = null): InstalledPackage
{
if (! config('marketplace.enabled')) {
throw new RuntimeException('The marketplace is disabled.');
}
$manifest = $this->manifests->parse($package->manifest ?: ['name' => $package->package_name, 'type' => $package->type, 'version' => $package->latest_version]);
$compatibility = $this->compatibility->check($manifest);
if (! $compatibility['compatible']) {
throw new RuntimeException(implode(' ', $compatibility['errors']));
}
$dependencies = $this->dependencies->resolve($manifest);
if (! $dependencies['satisfied']) {
throw new RuntimeException('Package dependencies are not satisfied.');
}
return DB::transaction(function () use ($package, $manifest, $userId) {
$installed = InstalledPackage::query()->firstOrNew(['package_name' => $package->package_name]);
$from = $installed->exists ? $installed->version : null;
$installed->fill(['marketplace_package_id' => $package->id, 'type' => $package->type, 'version' => $manifest['version'], 'status' => 'installed', 'manifest' => $manifest, 'checksum' => $package->checksum, 'is_enabled' => true, 'installed_by' => $userId, 'installed_at' => $installed->installed_at ?: now(), 'updated_at_marketplace' => now()])->save();
$this->history($installed, $from ? 'update' : 'install', $from, $manifest['version'], $userId, 'completed');
return $installed->refresh();
});
}
public function setEnabled(InstalledPackage $package, bool $enabled, ?int $userId = null): InstalledPackage
{
$package->update(['is_enabled' => $enabled, 'status' => $enabled ? 'installed' : 'disabled']);
$this->history($package, $enabled ? 'enable' : 'disable', $package->version, $package->version, $userId, 'completed');
return $package->refresh();
}
public function remove(InstalledPackage $package, ?int $userId = null): void
{
DB::transaction(function () use ($package, $userId) {
$this->history($package, 'remove', $package->version, null, $userId, 'completed');
$package->update(['status' => 'removed', 'is_enabled' => false]);
});
}
public function rollback(InstalledPackage $package, Installation $installation, ?int $userId = null): InstalledPackage
{
if ($installation->package_name !== $package->package_name || blank($installation->from_version)) {
throw new RuntimeException('This installation cannot be rolled back.');
}
$current = $package->version;
$package->update(['version' => $installation->from_version, 'status' => 'installed', 'is_enabled' => true]);
$this->history($package, 'rollback', $current, $installation->from_version, $userId, 'completed');
return $package->refresh();
}
private function history(InstalledPackage $package, string $operation, ?string $from, ?string $to, ?int $userId, string $status): Installation
{
return Installation::query()->create(['installed_package_id' => $package->id, 'package_name' => $package->package_name, 'operation' => $operation, 'from_version' => $from, 'to_version' => $to, 'status' => $status, 'performed_by' => $userId, 'started_at' => now(), 'completed_at' => now()]);
}
}
+38
View File
@@ -0,0 +1,38 @@
<?php
namespace Modules\Marketplace\Services;
use Illuminate\Support\Arr;
use Illuminate\Validation\ValidationException;
final class PackageManifest
{
public function parse(array|string $manifest): array
{
if (is_string($manifest)) {
$decoded = json_decode($manifest, true);
if (! is_array($decoded)) {
throw ValidationException::withMessages(['manifest' => 'The package manifest is not valid JSON.']);
}
$manifest = $decoded;
}
foreach (['name', 'type', 'version'] as $field) {
if (! filled(Arr::get($manifest, $field))) {
throw ValidationException::withMessages(['manifest' => "Manifest field [$field] is required."]);
}
}
if (! in_array($manifest['type'], config('marketplace.supported_types', []), true)) {
throw ValidationException::withMessages(['manifest' => 'Unsupported marketplace package type.']);
}
$manifest['requires'] = $manifest['requires'] ?? [];
$manifest['content_manager'] = $manifest['content_manager'] ?? '*';
$manifest['laravel'] = $manifest['laravel'] ?? '*';
return $manifest;
}
public function fromFile(string $path): array
{
return $this->parse((string) file_get_contents($path));
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
namespace Modules\Marketplace\Services;
use RuntimeException;
final class PackageVerifier
{
public function verify(string $path, array $manifest, ?string $expectedChecksum = null): array
{
if (! is_file($path)) {
throw new RuntimeException("Package archive [$path] does not exist.");
}
$actual = hash_file('sha256', $path);
$expected = $expectedChecksum ?: ($manifest['checksum'] ?? null);
if (config('marketplace.require_checksum', true) && blank($expected)) {
throw new RuntimeException('A SHA-256 checksum is required.');
}
if (filled($expected) && ! hash_equals(strtolower((string) $expected), strtolower($actual))) {
throw new RuntimeException('Package checksum verification failed.');
}
if (config('marketplace.require_signature', false) && blank($manifest['signature'] ?? null)) {
throw new RuntimeException('A package signature is required.');
}
return ['verified' => true, 'checksum' => $actual, 'signature_present' => filled($manifest['signature'] ?? null)];
}
}
@@ -0,0 +1,47 @@
<?php
namespace Modules\Marketplace\Services;
final class VersionConstraint
{
public function matches(string $version, string $constraint): bool
{
$constraint = trim($constraint);
if ($constraint === '' || $constraint === '*') {
return true;
}
foreach (preg_split('/\s*\|\|\s*/', $constraint) ?: [] as $alternative) {
$ok = true;
foreach (preg_split('/\s*,?\s+/', trim($alternative)) ?: [] as $part) {
if ($part === '') {
continue;
}
if (str_starts_with($part, '^')) {
$base = substr($part, 1);
$major = (int) explode('.', $base)[0];
$ok = $ok && version_compare($version, $base, '>=') && version_compare($version, ($major + 1).'.0.0', '<');
continue;
}
if (str_starts_with($part, '~')) {
$base = substr($part, 1);
$bits = array_pad(explode('.', $base), 3, '0');
$upper = $bits[0].'.'.((int) $bits[1] + 1).'.0';
$ok = $ok && version_compare($version, $base, '>=') && version_compare($version, $upper, '<');
continue;
}
if (preg_match('/^(>=|<=|>|<|=)?\s*([0-9][0-9A-Za-z.\-+]*)$/', $part, $m)) {
$ok = $ok && version_compare($version, $m[2], $m[1] ?: '=');
} else {
$ok = $ok && $version === $part;
}
}
if ($ok) {
return true;
}
}
return false;
}
}
@@ -0,0 +1,23 @@
<?php
namespace Modules\Marketplace\Tests\Feature;
use Modules\Marketplace\Services\CompatibilityChecker;
use Modules\Marketplace\Services\PackageManifest;
use Tests\TestCase;
final class MarketplaceModuleTest extends TestCase
{
public function test_manifest_parser_accepts_supported_package(): void
{
$manifest = app(PackageManifest::class)->parse(['name' => 'vendor/example', 'type' => 'module', 'version' => '1.0.0']);
$this->assertSame('module', $manifest['type']);
$this->assertSame([], $manifest['requires']);
}
public function test_current_platform_is_compatible_with_wildcard_manifest(): void
{
$result = app(CompatibilityChecker::class)->check(['php' => '*', 'laravel' => '*', 'content_manager' => '*']);
$this->assertTrue($result['compatible']);
}
}
+1
View File
@@ -0,0 +1 @@
{"name":"Marketplace","alias":"marketplace","version":"1.0.0","description":"Secure package catalogue, installation, updates, licences, publishers and rollback management.","keywords":["marketplace","modules","themes","extensions","packages"],"priority":170,"providers":["Modules\\Marketplace\\Providers\\MarketplaceServiceProvider"],"aliases":{},"files":[],"requires":["Core","Administration","Users","Roles","Audit","Developer","Security"]}