Files
feuerwerk/api/lib/PluginStore.php
T
2026-09-17 20:15:51 +02:00

141 lines
5.0 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
/**
* The editor plugins in `data/plugins/`: one ES module per `.js` file. Whether one is switched on
* is kept beside them in `plugins.json` (`{"demo-plugin.js": {"active": true}}`), server-wide; a
* file the state does not name is off, so a plugin put there runs only once someone switched it
* on (#114). The folder is not served statically: a module is only ever handed out by its bare
* file name, and only while it is active, so no request can reach past it.
*/
final class PluginStore
{
public const STATE_FILE = 'plugins.json';
public function __construct(private readonly string $dir)
{
}
/** A file name as it may be asked for: no path, no leading dot, `.js` at the end. */
public static function isValidName(string $name): bool
{
return (bool) preg_match('/^[A-Za-z0-9][A-Za-z0-9._-]*\.js$/', $name);
}
/**
* Sorted by file name, which is also the order the editor activates them in. Every plugin,
* active or not – the editor's Plugins tab is where one is switched on.
*
* @return list<array{file: string, bytes: int, updatedAt: int, active: bool}>
*/
public function list(): array
{
$files = is_dir($this->dir) ? scandir($this->dir) : [];
$state = $this->readState() ?? [];
$out = [];
foreach ($files === false ? [] : $files as $file) {
if ($this->exists($file)) {
$out[] = $this->entry($file, $state);
}
}
usort($out, static fn (array $a, array $b): int => strcmp($a['file'], $b['file']));
return $out;
}
public function isActive(string $file): bool
{
return $this->exists($file) && self::activeIn($this->readState() ?? [], $file);
}
/** The module's source, or null when there is no such plugin. */
public function read(string $file): ?string
{
if (!$this->exists($file)) {
return null;
}
$source = file_get_contents("{$this->dir}/{$file}");
return $source === false ? null : $source;
}
/**
* Switches a plugin on or off and returns its entry as `list` has it; null when there is no
* such plugin. The state file is rewritten whole under a lock, so two switches at once cannot
* lose each other's change. A state file that is not readable JSON is not overwritten: that
* would silently switch off everything it named.
*
* @return array{file: string, bytes: int, updatedAt: int, active: bool}|null
*/
public function setActive(string $file, bool $active): ?array
{
if (!$this->exists($file)) {
return null;
}
$lock = fopen("{$this->dir}/." . self::STATE_FILE . '.lock', 'c');
if ($lock === false || !flock($lock, LOCK_EX)) {
throw new RuntimeException('Could not lock the plugin state');
}
try {
$state = $this->readState();
if ($state === null) {
throw new RuntimeException(self::STATE_FILE . ' is not readable JSON – left as it is');
}
$state[$file] = ['active' => $active];
ksort($state);
$path = "{$this->dir}/" . self::STATE_FILE;
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
if (file_put_contents($tmp, $json) === false || !rename($tmp, $path)) {
@unlink($tmp);
throw new RuntimeException('Could not write ' . self::STATE_FILE);
}
return $this->entry($file, $state);
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
private function exists(string $file): bool
{
return self::isValidName($file) && is_file("{$this->dir}/{$file}");
}
/**
* The state as stored; an empty one when there is no file yet, null when there is one that
* cannot be read.
*
* @return array<string, mixed>|null
*/
private function readState(): ?array
{
$path = "{$this->dir}/" . self::STATE_FILE;
if (!is_file($path)) {
return [];
}
$raw = file_get_contents($path);
$state = $raw === false ? null : json_decode($raw, true);
return is_array($state) ? $state : null;
}
/** @param array<string, mixed> $state */
private static function activeIn(array $state, string $file): bool
{
return is_array($state[$file] ?? null) && ($state[$file]['active'] ?? false) === true;
}
/**
* @param array<string, mixed> $state
* @return array{file: string, bytes: int, updatedAt: int, active: bool}
*/
private function entry(string $file, array $state): array
{
$path = "{$this->dir}/{$file}";
return [
'file' => $file,
'bytes' => (int) filesize($path),
'updatedAt' => (int) filemtime($path),
'active' => self::activeIn($state, $file),
];
}
}