141 lines
5.0 KiB
PHP
141 lines
5.0 KiB
PHP
<?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),
|
||
];
|
||
}
|
||
}
|