312 lines
9.3 KiB
PHP
312 lines
9.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Domain\Plugin;
|
|
|
|
use App\Domain\Blog\ContentRenderer;
|
|
use App\Models\Plugin;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\File;
|
|
use Illuminate\Support\ServiceProvider;
|
|
use InvalidArgumentException;
|
|
use Mews\Purifier\Facades\Purifier;
|
|
use RuntimeException;
|
|
|
|
class PluginManager
|
|
{
|
|
/** @var array<string, array<string, mixed>> */
|
|
protected array $discovered = [];
|
|
|
|
public function discover(): Collection
|
|
{
|
|
$pluginPath = config('larablog.plugin_path', base_path('plugins'));
|
|
|
|
if (! is_dir($pluginPath)) {
|
|
return collect();
|
|
}
|
|
|
|
$plugins = collect();
|
|
|
|
foreach (File::directories($pluginPath) as $vendorDirectory) {
|
|
foreach (File::directories($vendorDirectory) as $pluginDirectory) {
|
|
$manifestPath = $pluginDirectory.'/plugin.json';
|
|
|
|
if (! is_file($manifestPath)) {
|
|
continue;
|
|
}
|
|
|
|
$manifest = json_decode((string) file_get_contents($manifestPath), true);
|
|
|
|
if (! is_array($manifest)) {
|
|
continue;
|
|
}
|
|
|
|
$name = (string) ($manifest['name'] ?? basename($pluginDirectory));
|
|
$relativePath = str_replace(base_path().'/', '', $pluginDirectory);
|
|
|
|
$plugins->put($name, array_merge($manifest, [
|
|
'name' => $name,
|
|
'path' => $pluginDirectory,
|
|
'relative_path' => $relativePath,
|
|
'provider' => $manifest['provider'] ?? null,
|
|
'requires' => array_values(array_filter(
|
|
array_map('strval', (array) ($manifest['requires'] ?? [])),
|
|
fn (string $item): bool => $item !== '',
|
|
)),
|
|
'optional' => array_values(array_filter(
|
|
array_map('strval', (array) ($manifest['optional'] ?? [])),
|
|
fn (string $item): bool => $item !== '',
|
|
)),
|
|
'docs' => (string) ($manifest['docs'] ?? 'README.md'),
|
|
]));
|
|
}
|
|
}
|
|
|
|
$this->discovered = $plugins->all();
|
|
|
|
return $plugins;
|
|
}
|
|
|
|
public function syncDiscoveredPlugins(): Collection
|
|
{
|
|
$discovered = $this->discover();
|
|
|
|
foreach ($discovered as $name => $manifest) {
|
|
Plugin::query()->updateOrCreate(
|
|
['name' => $name],
|
|
[
|
|
'version' => (string) ($manifest['version'] ?? '1.0.0'),
|
|
'path' => (string) ($manifest['relative_path'] ?? $manifest['path']),
|
|
],
|
|
);
|
|
}
|
|
|
|
return $discovered;
|
|
}
|
|
|
|
public function isEnabled(string $name): bool
|
|
{
|
|
return Plugin::query()
|
|
->where('name', $name)
|
|
->where('enabled', true)
|
|
->exists();
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public function missingRequires(string $name): array
|
|
{
|
|
$manifest = $this->discover()->get($name);
|
|
if ($manifest === null) {
|
|
throw new InvalidArgumentException("Plugin [{$name}] is not installed.");
|
|
}
|
|
|
|
$missing = [];
|
|
foreach ((array) ($manifest['requires'] ?? []) as $required) {
|
|
if (! $this->isEnabled((string) $required)) {
|
|
$missing[] = (string) $required;
|
|
}
|
|
}
|
|
|
|
return $missing;
|
|
}
|
|
|
|
public function enable(string $name): Plugin
|
|
{
|
|
$missing = $this->missingRequires($name);
|
|
if ($missing !== []) {
|
|
throw new RuntimeException(
|
|
__('admin.messages.plugin_requires', [
|
|
'plugin' => $name,
|
|
'requires' => implode(', ', $missing),
|
|
])
|
|
);
|
|
}
|
|
|
|
$plugin = $this->findPluginRecord($name);
|
|
$plugin->update(['enabled' => true]);
|
|
|
|
return $plugin->refresh();
|
|
}
|
|
|
|
public function disable(string $name): Plugin
|
|
{
|
|
$dependents = $this->enabledDependentsOf($name);
|
|
if ($dependents !== []) {
|
|
throw new RuntimeException(
|
|
__('admin.messages.plugin_required_by', [
|
|
'plugin' => $name,
|
|
'dependents' => implode(', ', $dependents),
|
|
])
|
|
);
|
|
}
|
|
|
|
$plugin = $this->findPluginRecord($name);
|
|
$plugin->update(['enabled' => false]);
|
|
|
|
return $plugin->refresh();
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public function enabledDependentsOf(string $name): array
|
|
{
|
|
$dependents = [];
|
|
|
|
foreach ($this->discover() as $candidate => $manifest) {
|
|
if ($candidate === $name || ! $this->isEnabled((string) $candidate)) {
|
|
continue;
|
|
}
|
|
|
|
$requires = array_map('strval', (array) ($manifest['requires'] ?? []));
|
|
if (in_array($name, $requires, true)) {
|
|
$dependents[] = (string) $candidate;
|
|
}
|
|
}
|
|
|
|
return $dependents;
|
|
}
|
|
|
|
public function docsPath(string $name, ?string $locale = null): ?string
|
|
{
|
|
$manifest = $this->discover()->get($name);
|
|
if ($manifest === null) {
|
|
return null;
|
|
}
|
|
|
|
$docs = trim((string) ($manifest['docs'] ?? 'README.md'));
|
|
if ($docs === '' || str_starts_with($docs, '/') || preg_match('#(^|[\\\\/])\.\.([\\\\/]|$)#', $docs) === 1) {
|
|
return null;
|
|
}
|
|
|
|
$root = realpath((string) $manifest['path']);
|
|
if ($root === false) {
|
|
return null;
|
|
}
|
|
|
|
foreach ($this->docsCandidates($docs, $locale ?? app()->getLocale()) as $relative) {
|
|
if (str_starts_with($relative, '/') || preg_match('#(^|[\\\\/])\.\.([\\\\/]|$)#', $relative) === 1) {
|
|
continue;
|
|
}
|
|
|
|
$path = realpath($root.DIRECTORY_SEPARATOR.str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $relative));
|
|
if ($path === false || ! is_file($path)) {
|
|
continue;
|
|
}
|
|
|
|
// Never read outside the plugin directory, even via symlinks.
|
|
if ($path !== $root && ! str_starts_with($path, $root.DIRECTORY_SEPARATOR)) {
|
|
continue;
|
|
}
|
|
|
|
return $path;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function readDocs(string $name, ?string $locale = null): ?string
|
|
{
|
|
$path = $this->docsPath($name, $locale);
|
|
if ($path === null) {
|
|
return null;
|
|
}
|
|
|
|
$contents = (string) file_get_contents($path);
|
|
|
|
return trim($contents) === '' ? null : $contents;
|
|
}
|
|
|
|
public function readDocsHtml(string $name, ?string $locale = null): ?string
|
|
{
|
|
$markdown = $this->readDocs($name, $locale);
|
|
if ($markdown === null) {
|
|
return null;
|
|
}
|
|
|
|
$html = app(ContentRenderer::class)->markdownToHtml($markdown);
|
|
|
|
return Purifier::clean($html, 'article');
|
|
}
|
|
|
|
/**
|
|
* Prefer README.{locale}.md, then README.{lang}.md, then the manifest docs file.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
protected function docsCandidates(string $docs, string $locale): array
|
|
{
|
|
$docs = str_replace('\\', '/', $docs);
|
|
$directory = dirname($docs);
|
|
$basename = basename($docs);
|
|
$extension = pathinfo($basename, PATHINFO_EXTENSION);
|
|
$stem = $extension !== ''
|
|
? substr($basename, 0, -strlen($extension) - 1)
|
|
: $basename;
|
|
$suffix = $extension !== '' ? '.'.$extension : '';
|
|
$prefix = ($directory === '.' || $directory === '') ? '' : $directory.'/';
|
|
|
|
$names = [];
|
|
$normalized = str_replace('-', '_', trim($locale));
|
|
if ($normalized !== '' && preg_match('/^[A-Za-z0-9_]+$/', $normalized) === 1) {
|
|
$names[] = $stem.'.'.$normalized.$suffix;
|
|
if (str_contains($normalized, '_')) {
|
|
$names[] = $stem.'.'.explode('_', $normalized, 2)[0].$suffix;
|
|
}
|
|
}
|
|
$names[] = $basename;
|
|
|
|
$candidates = [];
|
|
foreach ($names as $name) {
|
|
$relative = $prefix.$name;
|
|
if (! in_array($relative, $candidates, true)) {
|
|
$candidates[] = $relative;
|
|
}
|
|
}
|
|
|
|
return $candidates;
|
|
}
|
|
|
|
public function registerEnabledProviders(): void
|
|
{
|
|
$discovered = $this->discover();
|
|
|
|
Plugin::query()
|
|
->where('enabled', true)
|
|
->get()
|
|
->each(function (Plugin $plugin) use ($discovered): void {
|
|
$manifest = $discovered->get($plugin->name);
|
|
|
|
if ($manifest === null) {
|
|
return;
|
|
}
|
|
|
|
$providerClass = $manifest['provider'] ?? null;
|
|
|
|
if (! is_string($providerClass) || ! class_exists($providerClass)) {
|
|
return;
|
|
}
|
|
|
|
if (! is_subclass_of($providerClass, ServiceProvider::class)) {
|
|
return;
|
|
}
|
|
|
|
app()->register($providerClass);
|
|
});
|
|
}
|
|
|
|
protected function findPluginRecord(string $name): Plugin
|
|
{
|
|
$plugin = Plugin::query()->where('name', $name)->first();
|
|
|
|
if ($plugin === null) {
|
|
throw new InvalidArgumentException("Plugin [{$name}] is not installed.");
|
|
}
|
|
|
|
return $plugin;
|
|
}
|
|
}
|