phpvms/app/Addons/Services/AddonDiscoveryService.php
Nabeel Shahzad d0cf54c3de
refactor(addons): remove Module shim, legacy NWIDART bindings, and update controllers/tests
- Removed `Module` and `ModuleRepository` compatibility shims.
- Replaced NWIDART `Module` facade usages with the new `AddonRegistry`.
- Updated frontend controllers to use `AddonRegistry` for module handling.
- Added new feature tests to cover `AddonRegistry` lifecycle and rebuild cases.
- Updated bootstrapping logic and service bindings to drop NWIDART dependencies.

# Conflicts:
#	app/Models/Flight.php
#	app/Support/Utils.php
2026-06-12 15:04:35 -05:00

337 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Addons\Services;
use App\Addons\Models\AddonBootCache;
use App\Addons\Models\AddonManifest;
use App\Addons\Support\BootCache;
use App\Addons\Support\ManifestParser;
use App\Models\Addon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
/**
* Reconciliation core: scans both addon locations, upserts each discovered
* addon into the addons table, and regenerates the enabled-only boot cache.
*
* Design invariants (D-09 through D-15):
* - Idempotent: running multiple times produces the same DB state (D-14).
* - Enabled-flag preservation: operator disabled rows keep enabled=false (D-12).
* - Enabled-only cache: disabled addons are absent from the boot cache (D-13).
* - Malformed manifests: skipped with a warning; prime continues (D-15).
* - Cache-freshness gate: primeIfNeeded() runs when cache is absent or stale-schema (D2-09).
*
* Stateless and Octane-safe: no mutable instance properties.
*/
class AddonDiscoveryService
{
public function __construct(
private readonly ManifestParser $parser,
private readonly BootCache $bootCache,
) {}
/**
* Scan all addon locations and write the boot cache for a fresh install,
* and they're all marked as enabled
*
* If not a fresh install, upsert any newly found addons into the database,
* and set enabled = false, and also write the boot cache
*
* TODO: Should any new addons only be upserted and not written into the cache?
* I think at some point, we shouldn't write disabled addons in the cache
* For now, we'll just write them all to make testing easier
*/
public function run(): void
{
$manifests = $this->scanLocation(config('addons.paths.base'));
/** @var list<AddonBootCache> $rows */
$rows = [];
// Scenario 1: fresh install, all found addons are enabled
if (!installed()) {
foreach ($manifests as $m) {
$rows[] = $this->buildBootCacheRow($m, true);
}
$this->bootCache->write($rows);
return;
}
/**
* Scenario 2:
* This is an existing install
* Find any new addons that have popped up, set them to disabled
* Upsert them into the DB
*/
// This should be disabled by default, and only run the new
// addon discovery when you go to the admin panel for addons
if (!config('addons.scan_for_new_on_boot')) {
return;
}
$this->discoverNewAddons();
}
/**
* Run the prime only when the boot cache is absent or has a stale schema (D2-09).
*
* @return bool True when the prime was executed; false when cache was already fresh.
*/
public function primeIfNeeded(): bool
{
if ($this->bootCache->isFresh()) {
return false;
}
$this->run();
return true;
}
/**
* Discover new addons and update the boot cache.
*
* This method scans the predefined addon locations to discover new addons
* on disk. Detected addons are cross-referenced with the database to determine
* their enabled status.
*
* - Addons with a `registry_id`:
* - If found in the database, the enabled state is inherited.
* - If not found, the addon is considered new and disabled by default.
*
* - Addons without a `registry_id` (legacy):
* - If found in the database (searched by path), the enabled state is inherited.
* - If not found, the addon is treated as new and disabled by default.
*
* Newly discovered addons are upserted into the database where necessary.
* They're *not* inserted into the boot cache until they're installed
*
* Important: This also writes the newly discovered addons into the database
* but does not regenerate the boot cache. The boot cache is only regenerated
* when the plug-in is installed.
*
* @return Collection<Addon>
*/
public function discoverNewAddons(): Collection
{
$newAddons = collect();
$manifests = $this->scanLocation(config('addons.paths.base'));
if ($manifests === []) {
return $newAddons;
}
/** @var Collection<Addon> $installedAddons */
$installedAddons = Addon::query()->get();
/** @var AddonManifest $m */
foreach ($manifests as $m) {
// If the addon is already in the DB, don't do anything with it
$installed = $installedAddons->first(fn (Addon $addon, int $key): bool => ($addon->registry_id === $m->registryId)
|| ($addon->name === $m->name)
|| ($addon->namespace === $m->namespace));
if ($installed) {
continue;
}
$newAddons->push($this->upsert($m, isNew: true));
}
return $newAddons;
}
/**
* Regenerate the boot cache from current DB enabled state.
*
* Scans manifests on disk, matches each to its DB row, and writes only the
* enabled addons (enabled-only cache invariant, D-13). This is the cache
* regeneration that lifecycle mutations (enable/disable/delete/install/update)
* must call — run() does NOT rewrite the cache on an installed system.
*/
public function rebuildCache(): void
{
$manifests = $this->scanLocation(config('addons.paths.base'));
/** @var Collection<int, Addon> $installed */
$installed = Addon::where('enabled', true)->get();
/** @var list<AddonBootCache> $cacheRows */
$cacheRows = [];
foreach ($manifests as $m) {
$addon = $installed->first(fn (Addon $a): bool => ($a->registry_id === $m->registryId && $m->registryId !== null)
|| $a->name === $m->name
|| $a->namespace === $m->namespace);
if ($addon !== null) {
$cacheRows[] = $this->buildBootCacheRow($m, true);
}
}
$this->bootCache->write($cacheRows);
}
/**
* Enumerate immediate subdirectories of $dir and parse each manifest.
*
* Returns an empty array when the directory does not exist; modules
* may be absent on a fresh install (LOAD-01).
*
* Path-traversal guard (T-04-03): resolved realpath must stay within the
* scanned base dir; suspicious directories are skipped and logged.
*
* @return AddonManifest[]
*/
private function scanLocation(string $dir): array
{
if (!is_dir($dir)) {
return [];
}
$realBase = realpath($dir);
if ($realBase === false) {
return [];
}
$results = [];
foreach (File::directories($dir) as $subDir) {
$resolved = realpath($subDir);
// T-04-03: skip if the resolved path escapes the base directory.
if ($resolved === false || !str_starts_with($resolved, $realBase.DIRECTORY_SEPARATOR)) {
Log::warning(sprintf("AddonRuntimeService: skipping '%s' — path traversal guard triggered", $subDir));
continue;
}
$manifest = $this->parser->parse($resolved);
if (!$manifest instanceof AddonManifest) {
Log::warning(sprintf("AddonRuntimeService: skipping '%s' — module.json is missing or invalid", $resolved));
continue;
}
$results[] = $manifest;
}
return $results;
}
/**
* Build an AddonCacheEntry from a manifest and an explicit enabled flag.
*/
private function buildBootCacheRow(AddonManifest $m, bool $enabled): AddonBootCache
{
return new AddonBootCache(
name: $m->name,
alias: $m->alias,
type: $m->type,
registryId: $m->registryId,
version: $m->version,
namespace: $m->namespace,
providers: $m->providers,
path: $m->path,
autoloadPath: $m->autoloadPath,
layout: $m->layout,
description: $m->description,
enabled: $enabled,
filament: $this->probeFilament($m),
);
}
/**
* Upsert one addon row into the DB, preserving the operator's enabled flag (D-12).
*
* Match key:
* - registry_id when managed (non-null registryId).
* - path when bundled (null registryId).
*
* Uses firstOrNew + save so enabled is set only on row creation; an existing
* operator-disabled row keeps enabled=false across re-prime (D-12).
*/
private function upsert(AddonManifest $m, bool $isNew): Addon
{
$addon = Addon::fromManifest($m);
$addon->name = $m->name;
$addon->namespace = $m->namespace;
$addon->type = $m->type;
$addon->version = $m->version;
$addon->registry_id = $m->registryId;
$addon->path = $m->path;
if ($isNew) {
$addon->enabled = false;
$addon->installed_at = now();
}
$addon->save();
return $addon;
}
/**
* Probe convention-based Filament directories for a given addon.
*
* Filament base dir is derived from the already-computed autoloadPath:
* - 'root' layout: autoloadPath = addon dir → {autoloadPath}/Filament
* - 'app' layout: autoloadPath = addon/app → {autoloadPath}/Filament
*
* Probes for Resources, Pages, Widgets under both 'admin' and 'system' panels.
* Only includes subdirs that actually exist on disk.
*
* @return array<string, array<string, string>> panel => component => absolute path
*/
private function probeFilament(AddonManifest $m): array
{
$filamentBase = $m->autoloadPath.'/Filament';
$subdirs = ['Resources', 'Pages', 'Widgets'];
$result = [];
// Admin panel: {filamentBase}/{subdir}
$adminPaths = [];
foreach ($subdirs as $sub) {
$absPath = $filamentBase.'/'.$sub;
if (is_dir($absPath)) {
$real = realpath($absPath);
$adminPaths[$sub] = $real !== false ? $real : $absPath;
}
}
if ($adminPaths !== []) {
$result['admin'] = $adminPaths;
}
// System panel: {filamentBase}/System/{subdir}
$systemPaths = [];
foreach ($subdirs as $sub) {
$absPath = $filamentBase.'/System/'.$sub;
if (is_dir($absPath)) {
$real = realpath($absPath);
$systemPaths[$sub] = $real !== false ? $real : $absPath;
}
}
if ($systemPaths !== []) {
$result['system'] = $systemPaths;
}
return $result;
}
}