diff --git a/app/Platform/Modules/Http/Controllers/Admin/ModulesController.php b/app/Platform/Modules/Http/Controllers/Admin/ModulesController.php new file mode 100644 index 00000000..f8bbb147 --- /dev/null +++ b/app/Platform/Modules/Http/Controllers/Admin/ModulesController.php @@ -0,0 +1,114 @@ +authorize('manage modules'); + + $response = $client->catalog(); + $body = $response->json(); + $modules = is_array($body) ? ($body['modules'] ?? $body['data'] ?? null) : null; + + if (! $response->successful() || ! is_array($modules)) { + return response()->json(['error' => 'marketplace_unavailable'], 503); + } + + return ModuleResource::collection(collect($modules)); + } + + public function show(string $module, MarketplaceClient $client) + { + $this->authorize('manage modules'); + + $response = $client->module($module); + $body = $response->json(); + + if ($response->status() === 404) { + return response()->json(['error' => 'not_found'], 404); + } + + if (! $response->successful() || ! is_array($body) || ! is_array($body['module'] ?? null)) { + return response()->json(['error' => 'marketplace_unavailable'], 503); + } + + return (new ModuleResource($body['module'])) + ->additional(['meta' => [ + 'modules' => ModuleResource::collection( + collect($body['meta']['modules'] ?? []) + ), + ]]); + } + + public function enable(string $module): JsonResponse + { + $this->authorize('manage modules'); + + $module = ModelsModule::query() + ->where('name', $module) + ->where('installed', true) + ->firstOrFail(); + $installedModule = Module::find($module->name); + + if ($installedModule === null) { + $this->markRuntimeMissing($module); + + return response()->json([ + 'success' => false, + 'error' => 'module_runtime_missing', + ], 409); + } + + $installedModule->enable(); + $module->refresh(); + + event(new ModuleEnabledEvent($module)); + + return new JsonResponse(['success' => true]); + } + + public function disable(string $module, DatabaseActivator $activator): JsonResponse + { + $this->authorize('manage modules'); + + $module = ModelsModule::query() + ->where('name', $module) + ->where('installed', true) + ->firstOrFail(); + + $activator->setActiveByName($module->name, false); + + if (Module::find($module->name) === null) { + $this->markRuntimeMissing($module); + } else { + $module->refresh(); + } + + ModuleDisabledEvent::dispatch($module); + + return response()->json(['success' => true]); + } + + private function markRuntimeMissing(ModelsModule $module): void + { + $module->update([ + 'installed' => false, + 'enabled' => false, + 'state' => 'failed', + 'last_error' => 'module_runtime_missing', + 'last_failed_at' => now(), + ]); + } +} diff --git a/app/Platform/Modules/Http/Resources/ModuleResource.php b/app/Platform/Modules/Http/Resources/ModuleResource.php new file mode 100644 index 00000000..ea1ac4a6 --- /dev/null +++ b/app/Platform/Modules/Http/Resources/ModuleResource.php @@ -0,0 +1,152 @@ +resource, 'module_name'); + $installedModule = is_string($moduleName) + ? ModelsModule::where('name', $moduleName)->first() + : null; + $release = data_get($this->resource, 'release'); + $latestVersion = data_get($this->resource, 'latest_module_version') ?? data_get($release, 'version'); + $compatibility = data_get($this->resource, 'compatibility') ?? data_get($release, 'compatibility'); + $access = data_get($this->resource, 'access', 'free'); + + return [ + 'id' => data_get($this->resource, 'id'), + 'average_rating' => data_get($this->resource, 'average_rating'), + 'cover' => data_get($this->resource, 'cover'), + 'slug' => data_get($this->resource, 'slug'), + 'module_name' => $moduleName, + 'access_tier' => data_get($this->resource, 'access_tier') ?? ($access === 'paid' ? 'premium' : 'public'), + 'access' => $access, + 'entitlement' => data_get($this->resource, 'entitlement'), + 'compatibility' => $compatibility, + 'compatible' => data_get($this->resource, 'compatible'), + 'release_state' => data_get($this->resource, 'release_state') ?? data_get($release, 'state') ?? 'published', + 'yanked_reason' => data_get($this->resource, 'yanked_reason') ?? data_get($release, 'yanked_reason'), + 'channel' => data_get($this->resource, 'channel') ?? data_get($release, 'channel') ?? 'stable', + 'faq' => data_get($this->resource, 'faq'), + 'highlights' => data_get($this->resource, 'highlights'), + 'installed_module_version' => $this->getInstalledModuleVersion($installedModule), + 'installed_module_version_updated_at' => $this->getInstalledModuleUpdatedAt($installedModule), + 'latest_module_version' => $latestVersion, + 'latest_module_version_updated_at' => data_get($this->resource, 'latest_module_version_updated_at') ?? data_get($release, 'published_at'), + 'latest_min_invoiceshelf_version' => data_get($this->resource, 'latest_min_invoiceshelf_version'), + 'latest_module_checksum_sha256' => data_get($this->resource, 'latest_module_checksum_sha256') ?? data_get($release, 'artifact.sha256'), + 'is_dev' => (bool) data_get($this->resource, 'is_dev', false), + 'license' => data_get($this->resource, 'license', 'AGPL-3.0-only'), + 'long_description' => data_get($this->resource, 'long_description'), + 'monthly_price' => data_get($this->resource, 'monthly_price'), + 'name' => data_get($this->resource, 'name'), + 'purchased' => data_get($this->resource, 'purchased') ?? ($access === 'free' || (bool) data_get($this->resource, 'entitlement.active', false)), + 'purchase_url' => data_get($this->resource, 'purchase_url'), + 'reviews' => data_get($this->resource, 'reviews', []), + 'screenshots' => data_get($this->resource, 'screenshots'), + 'short_description' => data_get($this->resource, 'short_description'), + 'type' => data_get($this->resource, 'type'), + 'yearly_price' => data_get($this->resource, 'yearly_price'), + 'author_name' => data_get($this->resource, 'author_name') ?? data_get($this->resource, 'author.name'), + 'author_avatar' => data_get($this->resource, 'author_avatar') ?? data_get($this->resource, 'author.avatar'), + 'installed' => $this->moduleInstalled($installedModule), + 'enabled' => $this->moduleEnabled($installedModule), + 'supports_data_cleanup' => $this->supportsDataCleanup($installedModule), + 'update_available' => $this->updateAvailable($installedModule, $latestVersion), + 'video_link' => data_get($this->resource, 'video_link') ?? data_get($this->resource, 'video.url'), + 'video_thumbnail' => data_get($this->resource, 'video_thumbnail') ?? data_get($this->resource, 'video.thumbnail'), + 'links' => data_get($this->resource, 'links'), + ]; + } + + public function getInstalledModuleVersion(?ModelsModule $installedModule): ?string + { + if ($installedModule && $installedModule->installed) { + return $installedModule->version; + } + + return null; + } + + public function getInstalledModuleUpdatedAt(?ModelsModule $installedModule): ?string + { + if ($installedModule && $installedModule->installed) { + return $installedModule->updated_at?->toIso8601String(); + } + + return null; + } + + public function moduleInstalled(?ModelsModule $installedModule): bool + { + return (bool) ($installedModule?->installed); + } + + public function moduleEnabled(?ModelsModule $installedModule): bool + { + return (bool) ($installedModule?->installed && $installedModule?->enabled); + } + + public function updateAvailable(?ModelsModule $installedModule, mixed $latestVersion): bool + { + if (! $installedModule || ! $installedModule->installed) { + return false; + } + + if (! is_string($latestVersion)) { + return false; + } + + return version_compare($installedModule->version, $latestVersion, '<'); + } + + public function supportsDataCleanup(?ModelsModule $installedModule): bool + { + if (! $installedModule?->installed) { + return false; + } + + $path = base_path('Modules/'.$installedModule->name.'/module.json'); + $metadata = File::isFile($path) ? json_decode((string) File::get($path), true) : null; + $uninstall = is_array($metadata) ? ($metadata['uninstall'] ?? null) : null; + $cleanup = is_array($uninstall) ? ($uninstall['data_cleanup'] ?? null) : null; + $contract = 'InvoiceShelf\\Modules\\Contracts\\DataCleanup'; + + if (! (is_array($metadata) + && ($metadata['schema_version'] ?? null) === 2 + && ($metadata['migration_policy'] ?? null) === 'reversible' + && is_array($uninstall) + && array_keys($uninstall) === ['data_cleanup'] + && is_string($cleanup) + && interface_exists($contract))) { + return false; + } + + try { + $reflection = new \ReflectionClass($cleanup); + + return ! $reflection->isAbstract() + && $reflection->isInstantiable() + && is_a($cleanup, $contract, true) + && $reflection->hasMethod('cleanup') + && $reflection->getMethod('cleanup')->isPublic() + && ! $reflection->getMethod('cleanup')->isStatic(); + } catch (\Throwable) { + return false; + } + } +}