move into sonardesk
This commit is contained in:
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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, '>');
|
||||
}
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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()]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
@@ -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"]}
|
||||
Reference in New Issue
Block a user