Plugins are switched on and off in a Plugins tab, the server keeps the state in data/plugins/plugins.json, and switched-off plugins are never delivered (#114)
game-maker / gate (push) Successful in 1m38s
game-maker / suite (push) Successful in 5m43s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-17 20:15:51 +02:00
1 parent 57a97ba94f
commit c17b2b5426
17 files changed
+994 -129

No files matched your search

+25 -5
View File
@@ -25,8 +25,9 @@ declare(strict_types=1);
* POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes}
* DELETE ?r=sounds/{file} → 204
*
* GET ?r=plugins → [{file,bytes,updatedAt}], sorted by file name
* GET ?r=plugins/{file} → the module source as text/javascript
* GET ?r=plugins → [{file,bytes,updatedAt,active}], sorted by file name
* GET ?r=plugins/{file} → the module source as text/javascript | 403 while switched off
* PUT ?r=plugins/{file} {active} → {file,bytes,updatedAt,active}
*
* `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and
* empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`.
@@ -220,19 +221,38 @@ function routeSounds(array $parts, string $method): never
/** @param list<string> $parts */
function routePlugins(array $parts, string $method): never
{
if ($method !== 'GET') {
Response::error('Method not allowed', 405);
}
$store = new PluginStore(dirname(__DIR__) . '/data/plugins');
if (count($parts) === 1) {
if ($method !== 'GET') {
Response::error('Method not allowed', 405);
}
Response::json($store->list());
}
if ($method === 'PUT') {
$active = Response::body()['active'] ?? null;
if (!is_bool($active)) {
Response::error('active must be true or false', 422);
}
$entry = $store->setActive($parts[1], $active);
if ($entry === null) {
Response::error('Plugin not found', 404);
}
Response::json($entry);
}
if ($method !== 'GET') {
Response::error('Method not allowed', 405);
}
$source = $store->read($parts[1]);
if ($source === null) {
Response::error('Plugin not found', 404);
}
// Switched off means not run: the editor never asks for such a module, and nobody else may.
if (!$store->isActive($parts[1])) {
Response::error('Plugin not active', 403);
}
header('Content-Type: text/javascript; charset=utf-8');
header('X-Content-Type-Options: nosniff');
// A replaced file has to be the one the next editor start runs.
+101 -13
View File
@@ -2,13 +2,16 @@
declare(strict_types=1);
/**
* The editor plugins in `data/plugins/`: one ES module per `.js` file, and every file there is
* active. Read-only for now – putting a file there is done by hand, by the build and by the e2e
* seeder. The folder is not served statically: a module is only ever handed out by its bare file
* name, so no request can reach past it.
* 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)
{
}
@@ -20,33 +23,118 @@ final class PluginStore
}
/**
* Sorted by file name, which is also the order the editor activates them in.
* 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}>
* @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) {
$path = "{$this->dir}/{$file}";
if (!self::isValidName($file) || !is_file($path)) {
continue;
if ($this->exists($file)) {
$out[] = $this->entry($file, $state);
}
$out[] = ['file' => $file, 'bytes' => (int) filesize($path), 'updatedAt' => (int) filemtime($path)];
}
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
{
$path = "{$this->dir}/{$file}";
if (!self::isValidName($file) || !is_file($path)) {
if (!$this->exists($file)) {
return null;
}
$source = file_get_contents($path);
$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),
];
}
}