Marketplace package catalogue

* Modules, themes, and extensions
* Publisher profiles and verification metadata
* Installed-package tracking
* Semantic version compatibility checks
* PHP, Laravel, and Content Manager compatibility checks
* Dependency resolution
* SHA-256 checksum validation
* Package signature requirements
* Installation and update history
* Enable and disable controls
* Removal handling
* Rollback records
* Licence records
* Marketplace administration UI
* Authenticated REST endpoints
* Console audit and synchronisation commands
* Permissions and sidebar integration
* Feature tests
This commit is contained in:
Mark Gregory
2026-07-24 13:49:14 +01:00
parent 673554d92d
commit f09a680531
29 changed files with 617 additions and 11 deletions
+13
View File
@@ -0,0 +1,13 @@
<?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,14 @@
<?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,12 @@
<?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,48 @@
<?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,16 @@
<?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,24 @@
<?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.'); }
}
@@ -0,0 +1,9 @@
<?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'];
}
@@ -0,0 +1,14 @@
<?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,'>'); }
}
@@ -0,0 +1,10 @@
<?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'];
}
@@ -0,0 +1,13 @@
<?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'); }
}
+11
View File
@@ -0,0 +1,11 @@
<?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,30 @@
<?php
namespace Modules\Marketplace\Providers;
use Illuminate\Support\ServiceProvider;
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([\Modules\Marketplace\Console\Commands\MarketplaceSyncCommand::class, \Modules\Marketplace\Console\Commands\MarketplaceAuditCommand::class]);
}
}
}
@@ -0,0 +1,12 @@
<?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>
+10
View File
@@ -0,0 +1,10 @@
<?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');
});
+12
View File
@@ -0,0 +1,12 @@
<?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,17 @@
<?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,17 @@
<?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,39 @@
<?php
namespace Modules\Marketplace\Services;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Modules\Marketplace\Models\Installation;
use Modules\Marketplace\Models\InstalledPackage;
use Modules\Marketplace\Models\MarketplacePackage;
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()]); }
}
@@ -0,0 +1,22 @@
<?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)); }
}
@@ -0,0 +1,16 @@
<?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,22 @@
<?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,18 @@
<?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"]}
@@ -80,6 +80,7 @@ final class RolePermissionSeeder extends Seeder
'analytics.manage',
'analytics.export',
'builder.view', 'builder.manage', 'builder.publish',
'marketplace.view', 'marketplace.install', 'marketplace.update', 'marketplace.remove',
];
foreach ($permissions as $permission) {
+2 -1
View File
@@ -42,5 +42,6 @@
"Automation": true,
"AI": true,
"Analytics": true,
"Builder": true
"Builder": true,
"Marketplace": true
}
+13 -5
View File
@@ -5,18 +5,26 @@
colors="true"
>
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
<testsuite name="Application Unit">
<directory suffix="Test.php">tests/Unit</directory>
</testsuite>
<testsuite name="Feature">
<directory>tests/Feature</directory>
<testsuite name="Application Feature">
<directory suffix="Test.php">tests/Feature</directory>
</testsuite>
<testsuite name="Modules">
<directory suffix="Test.php">Modules</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>app</directory>
<directory suffix=".php">app</directory>
<directory suffix=".php">Modules</directory>
</include>
</source>
<php>
<env name="APP_ENV" value="testing" force="true"/>
<server name="APP_ENV" value="testing" force="true"/>
-5
View File
@@ -1,5 +0,0 @@
<?php
test('that true is true', function () {
expect(true)->toBeTrue();
});