Claude Code should be able to work inside the user's open editor tab. This first stage connects the two and lets the agent look: what the user sees and a picture of it. Changing the project comes in stage 2. - An agent token per account opens only /mcp, no API route. The dialog "Agent tokens..." in the account menu creates it once, shows the ready "claude mcp add" line and revokes it. The server keeps only its sha256 (api/lib/AgentTokens.php). - Wrong tokens count into the sign-in brake from #427. Changing the password, renaming or deleting an account ends its tokens. - The relay (tools/mcp/) is a small Node service without packages, WebSocket server included. It runs in the container feuerwerk-mcp because PHP-FPM cannot hold connections. Whether a token or a tab ticket is valid is still decided by the PHP API, through two routes that answer only with the relay key. - The tab joins over WebSocket on the same origin with the normal session, an Origin check and a one-time ticket. It connects only after "Agent" is switched on in the account menu. One tab per account is active, a new one takes over. Guests get nothing. - Tools: get_view (project, tab, document, selection with names, camera, play state, open dialog) and screenshot. The resource feuerwerk://view can be subscribed. Claude Code does not act on that reliably, so a changed view is also noted on the next answer. - A badge next to the account menu shows the connected agents. It counts only agents with an open stream or a request in the last two minutes. - CI starts the relay in front of php -S (tools/ci/serve.sh), and e2e 71 runs the whole path in the browser. Found on the live test: the badge showed four agents because old sessions of revoked tokens were still counted. Tests: - Gate on atx: 316 files, 4888 tests - relay protocol test with fake API and fake tabs: 23/23 - get_view, tools, socketUrl: 16/16 - PHP agents 29, rights 103 checks - e2e 71 on atx 10/10, against the live site 10/10, no CSP violation - real Claude Code against the live site: get_view answered - ZAP against /mcp: nothing at High or Medium - every new check fails when the guard it covers is taken out (6) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1083 lines
44 KiB
PHP
1083 lines
44 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Feuerwerk API.
|
||
*
|
||
* Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the
|
||
* site's nginx configuration does not pass PATH_INFO to PHP.
|
||
*
|
||
* GET ?r=auth/session → {user, role} (both null while nobody is signed in)
|
||
* POST ?r=auth/login {username,password} → {user, role} | 401 | 429
|
||
* POST ?r=auth/logout → 204
|
||
* POST ?r=auth/password {currentPassword,newPassword} → 204 | 401 | 422 | 429
|
||
* (the only auth route that needs a session; ends the account's
|
||
* other logins, keeps the one asking)
|
||
*
|
||
* Five wrong passwords from one address within 15 minutes, and it gets 429 with `Retry-After` for
|
||
* the next 15 (see LoginThrottle, #427). Wrong agent tokens count alike.
|
||
*
|
||
* The MCP relay (#429, tools/mcp/) asks two routes of its own, with the key both sides read from
|
||
* `data/mcp-relay.key` in `X-Relay-Key`; without it they answer 404 like a route that is not there:
|
||
*
|
||
* POST ?r=agent/verify {token,address} → {user, role} | 401 | 429
|
||
* (`address` is the agent's, which the relay saw; the brake counts it)
|
||
* POST ?r=agent/redeem {ticket} → {user, role} | 401
|
||
*
|
||
* A request that changes something (anything but GET) is refused with 403 when the browser says it
|
||
* comes from another site, `Sec-Fetch-Site` being `cross-site` or `same-site`: the session cookie is
|
||
* SameSite=Lax, and every subdomain of the site counts as the same site to it. Tools send no such
|
||
* header and their own cookie.
|
||
*
|
||
* An account takes up at most 1 GB with its uploads, projects and snapshots (see Quota); a write that
|
||
* would make it larger is refused with 413. Administrators have no limit.
|
||
*
|
||
* Everything below needs a signed-in user (401 otherwise), and projects belong to one: a project
|
||
* of somebody else answers like one that is not there – unless the one asking is an administrator
|
||
* (#179), who reads and saves it like their own; a save keeps its owner. Deleting a project is
|
||
* its owner's alone (#272).
|
||
*
|
||
* Administrators only (403 for everybody else, #180):
|
||
*
|
||
* GET ?r=users → [{username,role,createdAt,projects:[{id,name,revision,updatedAt}]}]
|
||
* POST ?r=users {username,password,role} → 201 {username,role,createdAt} | 409 | 422
|
||
* PATCH ?r=users/{name} {username?,role?,password?} → {username,role,createdAt} | 404 | 409 | 422
|
||
* DELETE ?r=users/{name} → 204 | 404 | 409 (still owns projects) | 422
|
||
* (renaming or deleting signs the account out everywhere; a new
|
||
* password ends its other sessions)
|
||
*
|
||
* Agent tokens (#429) open the relay for an agent of the account and nothing else; a ticket gets a
|
||
* tab onto it (see AgentTokens). Neither is for a guest:
|
||
*
|
||
* GET ?r=agent-tokens → [{id,name,createdAt,expiresAt,usedAt}], oldest first
|
||
* POST ?r=agent-tokens {name,days?} → 201 {id,name,createdAt,expiresAt,usedAt,token} | 409 | 422
|
||
* (the one answer the token is in; `days` 1–365, 90 without)
|
||
* DELETE ?r=agent-tokens/{id} → 204 | 404
|
||
* POST ?r=agent/ticket → {ticket, expiresIn}
|
||
*
|
||
* GET ?r=projects → [{id,name,revision,updatedAt}]
|
||
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
|
||
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
|
||
* PUT ?r=projects/{id} {revision, project} → {revision,updatedAt} | 409 {error,revision}
|
||
* DELETE ?r=projects/{id} → 204 | 403 | 404 (its snapshots and its release go with it;
|
||
* 403 for an administrator who is not the owner)
|
||
*
|
||
* GET ?r=projects/{id}/snapshots → [{id,name,revision,createdAt}], newest first
|
||
* POST ?r=projects/{id}/snapshots {name?} → 201 {id,name,revision,createdAt}
|
||
* DELETE ?r=projects/{id}/snapshots/{sid} → 204
|
||
* POST ?r=projects/{id}/snapshots/{sid}/restore {revision} → {revision,updatedAt,before} | 409
|
||
* (a snapshot copies the project as stored; restoring keeps the
|
||
* stand it replaces as the snapshot `before`, #231)
|
||
*
|
||
* GET ?r=projects/{id}/release → {token,revision,publishedAt} | null
|
||
* POST ?r=projects/{id}/release → {token,revision,publishedAt} (publishes the stored project)
|
||
* DELETE ?r=projects/{id}/release → 204 | 404
|
||
* (one link per project, kept when publishing again, #266)
|
||
*
|
||
* Released games are open to everybody, the token being the key:
|
||
*
|
||
* GET ?r=releases/{token} → {name,publishedAt,project} | 404
|
||
*
|
||
* So are the demo copies a guest opens in the editor (#428), frozen copies like a release that an
|
||
* administrator makes and updates per project:
|
||
*
|
||
* GET ?r=demos → [{token,name,publishedAt}], by name
|
||
* GET ?r=demos/{token} → {name,publishedAt,project} | 404
|
||
* GET ?r=projects/{id}/demo → {token,revision,publishedAt} | null (administrators only)
|
||
* POST ?r=projects/{id}/demo → {token,revision,publishedAt} (administrators only)
|
||
* DELETE ?r=projects/{id}/demo → 204 | 404 (administrators only)
|
||
*
|
||
* GET ?r=textures → [{file,name,width,height,bytes,updatedAt,owner,deletable}]
|
||
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
|
||
* DELETE ?r=textures/{file} → 204 | 403 | 404 | 409 (still used)
|
||
*
|
||
* GET ?r=fonts → [{file,name,bytes,updatedAt,owner,deletable}]
|
||
* POST ?r=fonts&name=x.ttf raw font bytes → {file,name,bytes}
|
||
* DELETE ?r=fonts/{file} → 204 | 403 | 404 | 409
|
||
*
|
||
* GET ?r=sounds → [{file,name,bytes,updatedAt,owner,deletable}]
|
||
* POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes}
|
||
* DELETE ?r=sounds/{file} → 204 | 403 | 404 | 409
|
||
*
|
||
* GET ?r=videos → [{file,name,status,…,original,owner,deletable}]
|
||
* POST ?r=videos&name=x.mov[&convert=0] raw video bytes → {file,name,status,…}
|
||
* POST ?r=videos/{file}/convert → {file,name,status,…} | 403 | 404
|
||
* DELETE ?r=videos/{file} → 204 | 403 | 404 | 409
|
||
* (an upload is converted by the worker in `feuerwerk-video` (#402):
|
||
* `status` is queued, converting with `progress`, failed with `error`,
|
||
* or ready with width, height, duration, fps, audio and `poster`;
|
||
* with `convert=0` it is only kept, `unconverted`, until it is asked
|
||
* to be. `original` is the upload as it came, null for a video
|
||
* uploaded before uploads were kept)
|
||
*
|
||
* Uploads land in the uploader's own folder, and `file` carries it: `{folder}/{sha256}.{ext}`
|
||
* (#344). A `file` without a folder belongs to nobody. A list shows the files of the one asking and
|
||
* nobody's, an administrator's every file; `owner` is the account a file belongs to (null for
|
||
* nobody, '' for a deleted account). A file goes only at its owner's or an administrator's hand,
|
||
* and only while no project, snapshot, release or demo copy uses it.
|
||
*
|
||
* An administrator offers a file to everybody as demo material (#428); every list carries those
|
||
* files too, with `demo: true`, and without a session a list holds nothing else. Only an
|
||
* administrator deletes one while it is offered.
|
||
*
|
||
* PATCH ?r=textures/{file} {demo} → 204 | 403 | 404 | 422 (administrators only)
|
||
* PATCH ?r=fonts/{file} {demo} → the same
|
||
* PATCH ?r=sounds/{file} {demo} → the same
|
||
* PATCH ?r=videos/{file} {demo} → the same
|
||
*
|
||
* POST ?r=pixels raw cel bytes → {file,bytes} (pixel art, #130)
|
||
*
|
||
* GET ?r=plugins → [{file,bytes,updatedAt}], sorted by file name
|
||
* GET ?r=plugins/{file} → the module source as text/javascript
|
||
* (both without a session as well: a guest's demo may switch one on, #428)
|
||
*
|
||
* `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}`
|
||
* in a folder.
|
||
*
|
||
* Stored images, fonts, sounds, videos and cels are served straight from `data/textures/`, `data/fonts/`,
|
||
* `data/sounds/`, `data/videos/` and `data/pixels/` as static files; the API only writes them.
|
||
*/
|
||
|
||
require __DIR__ . '/lib/AccountRules.php';
|
||
require __DIR__ . '/lib/AgentTokens.php';
|
||
require __DIR__ . '/lib/AtomicFile.php';
|
||
require __DIR__ . '/lib/Auth.php';
|
||
require __DIR__ . '/lib/DemoFiles.php';
|
||
require __DIR__ . '/lib/FileUsage.php';
|
||
require __DIR__ . '/lib/HashedFileStore.php'; // before the font, sound and texture stores built on it
|
||
require __DIR__ . '/lib/FontStore.php';
|
||
require __DIR__ . '/lib/LoginThrottle.php';
|
||
require __DIR__ . '/lib/OriginalName.php';
|
||
require __DIR__ . '/lib/PixelStore.php';
|
||
require __DIR__ . '/lib/PluginStore.php';
|
||
require __DIR__ . '/lib/Quota.php';
|
||
require __DIR__ . '/lib/ReleaseStore.php';
|
||
require __DIR__ . '/lib/Response.php';
|
||
require __DIR__ . '/lib/SessionStore.php';
|
||
require __DIR__ . '/lib/SnapshotStore.php';
|
||
require __DIR__ . '/lib/SoundStore.php';
|
||
require __DIR__ . '/lib/Store.php';
|
||
require __DIR__ . '/lib/TextureStore.php';
|
||
require __DIR__ . '/lib/UserStore.php';
|
||
require __DIR__ . '/lib/VideoQueue.php';
|
||
require __DIR__ . '/lib/VideoStore.php';
|
||
|
||
/**
|
||
* Has to clear the largest upload held in memory, which is a sound; a video is streamed to disk and
|
||
* has its own ceiling. The web server and the proxy in front of it have their own say.
|
||
*/
|
||
const MAX_BODY_BYTES = 256 * 1024 * 1024;
|
||
|
||
/** Upload ceiling for a single image. The editor downscales larger pictures before sending. */
|
||
const MAX_TEXTURE_BYTES = 4 * 1024 * 1024;
|
||
|
||
/** Upload ceiling for a single font. A font cannot be shrunk on the way, so this is a plain limit. */
|
||
const MAX_FONT_BYTES = 4 * 1024 * 1024;
|
||
|
||
/** The same for a sound, far higher: a recorded take is plain WAV, about 46 minutes in mono. */
|
||
const MAX_SOUND_BYTES = 256 * 1024 * 1024;
|
||
|
||
/**
|
||
* A video goes up as it was recorded and is made smaller by the worker, not on the way. It is
|
||
* streamed to disk, so memory is no limit; nginx and the Brandmauer let 2 GB through (#402).
|
||
*/
|
||
const MAX_VIDEO_BYTES = 2048 * 1024 * 1024;
|
||
|
||
/**
|
||
* Where everything the API keeps lies, and how much of it an account may fill. The rights test
|
||
* (tests/php/rights.test.php) runs the API under `php -S` on a scratch directory with a small quota;
|
||
* behind nginx neither can be moved.
|
||
*/
|
||
define('DATA_DIR', testSetting('FEUERWERK_DATA') ?? dirname(__DIR__) . '/data');
|
||
define('QUOTA_BYTES', (int) (testSetting('FEUERWERK_QUOTA_BYTES') ?? Quota::BYTES));
|
||
|
||
function testSetting(string $name): ?string
|
||
{
|
||
$value = PHP_SAPI === 'cli-server' ? getenv($name) : false;
|
||
return is_string($value) && $value !== '' ? $value : null;
|
||
}
|
||
|
||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||
// PHP calls the handler for a call silenced with `@` as well, only with less reported: that
|
||
// warning was expected and is answered where it happens (an unreadable image is a 422).
|
||
if ((error_reporting() & $severity) === 0) {
|
||
return false;
|
||
}
|
||
throw new ErrorException($message, 0, $severity, $file, $line);
|
||
});
|
||
|
||
/** @param list<string> $parts */
|
||
function routeAuth(array $parts, string $method, Auth $auth): never
|
||
{
|
||
$what = $parts[1] ?? '';
|
||
$throttle = new LoginThrottle(DATA_DIR . '/sign-ins');
|
||
|
||
if ($what === 'session') {
|
||
if ($method !== 'GET') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
// Always 200, never 401: "nobody is signed in" is an answer, not a failure, and the
|
||
// login screen asks this before it can know either way.
|
||
$user = $auth->user();
|
||
Response::json(['user' => $user, 'role' => $user === null ? null : $auth->role($user)]);
|
||
}
|
||
|
||
if ($what === 'login') {
|
||
if ($method !== 'POST') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
$body = Response::body();
|
||
refuseWhileThrottled($throttle);
|
||
$user = $auth->logIn((string) ($body['username'] ?? ''), (string) ($body['password'] ?? ''));
|
||
if ($user === null) {
|
||
countFailure($throttle);
|
||
// One message for both halves: which of the two was wrong is the attacker's question.
|
||
Response::error('Wrong user name or password', 401);
|
||
}
|
||
Response::json(['user' => $user, 'role' => $auth->role($user)]);
|
||
}
|
||
|
||
if ($what === 'password') {
|
||
if ($method !== 'POST') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
// The one route under `auth/` that needs a session: it changes the password of whoever is
|
||
// signed in, so there has to be somebody.
|
||
$user = $auth->require();
|
||
$body = Response::body();
|
||
$new = (string) ($body['newPassword'] ?? '');
|
||
if (!UserStore::isValidPassword($new)) {
|
||
Response::error('The new password needs at least ' . UserStore::MIN_PASSWORD_LENGTH . ' characters', 422);
|
||
}
|
||
// Guessing the current password from an open tab is guessing all the same.
|
||
refuseWhileThrottled($throttle);
|
||
if (!$auth->changePassword($user, (string) ($body['currentPassword'] ?? ''), $new)) {
|
||
countFailure($throttle);
|
||
Response::error('Wrong password', 401);
|
||
}
|
||
agentTokens()->endAllOf($user);
|
||
Response::noContent();
|
||
}
|
||
|
||
if ($what === 'logout') {
|
||
if ($method !== 'POST') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
$auth->logOut();
|
||
Response::noContent();
|
||
}
|
||
|
||
Response::error('Not found', 404);
|
||
}
|
||
|
||
/** The address a request came from: nginx puts the one the Brandmauer saw there (#427). */
|
||
function clientAddress(): string
|
||
{
|
||
return (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||
}
|
||
|
||
function countFailure(LoginThrottle $throttle, ?string $address = null): void
|
||
{
|
||
$throttle->fail($address ?? clientAddress());
|
||
// Now and then, so the folder holds the addresses of the last quarter of an hour and no more.
|
||
if (random_int(1, 20) === 1) {
|
||
$throttle->prune();
|
||
}
|
||
}
|
||
|
||
function refuseWhileThrottled(LoginThrottle $throttle, ?string $address = null): void
|
||
{
|
||
$wait = $throttle->waitFor($address ?? clientAddress());
|
||
if ($wait === 0) {
|
||
return;
|
||
}
|
||
header('Retry-After: ' . $wait);
|
||
Response::error('Too many failed sign-ins. Try again in ' . (int) ceil($wait / 60) . ' minutes', 429);
|
||
}
|
||
|
||
/**
|
||
* Bytes a project takes up as stored, 0 for one that is not there.
|
||
*/
|
||
function projectBytes(string $id): int
|
||
{
|
||
$path = DATA_DIR . "/projects/{$id}.json";
|
||
return is_file($path) ? (int) filesize($path) : 0;
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $parts
|
||
* @param Closure(int):void $fits answers 413 when that many more bytes do not fit the quota
|
||
*/
|
||
function routeProjects(array $parts, string $method, string $owner, bool $seesAll, Closure $fits): never
|
||
{
|
||
$store = new Store(DATA_DIR . '/projects', $owner, $seesAll);
|
||
|
||
if (count($parts) === 1) {
|
||
if ($method === 'GET') {
|
||
Response::json($store->list());
|
||
}
|
||
if ($method === 'POST') {
|
||
$body = Response::body();
|
||
$name = trim((string) ($body['name'] ?? ''));
|
||
if ($name === '' || mb_strlen($name) > 120) {
|
||
Response::error('Project name must be 1–120 characters', 422);
|
||
}
|
||
// What an empty project takes up, give or take its name.
|
||
$fits(1024);
|
||
Response::json($store->create($name), 201);
|
||
}
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
$id = $parts[1];
|
||
if (!Store::isValidId($id)) {
|
||
Response::error('Invalid project id', 400);
|
||
}
|
||
|
||
$snapshots = new SnapshotStore(DATA_DIR . '/snapshots');
|
||
if (($parts[2] ?? null) === 'snapshots') {
|
||
routeSnapshots($parts, $method, $store, $snapshots, $id, $fits);
|
||
}
|
||
$releases = new ReleaseStore(DATA_DIR . '/releases');
|
||
if (($parts[2] ?? null) === 'release' && count($parts) === 3) {
|
||
routeRelease($method, $store, $releases, $id);
|
||
}
|
||
$demos = new ReleaseStore(DATA_DIR . '/demos');
|
||
if (($parts[2] ?? null) === 'demo' && count($parts) === 3) {
|
||
// What guests get to see is the administrators' call (#428).
|
||
if (!$seesAll) {
|
||
Response::error('Administrators only', 403);
|
||
}
|
||
routeRelease($method, $store, $demos, $id, 'Project has no demo copy');
|
||
}
|
||
if (count($parts) > 2) {
|
||
Response::error('Not found', 404);
|
||
}
|
||
|
||
if ($method === 'GET') {
|
||
$doc = $store->load($id);
|
||
if ($doc === null) {
|
||
Response::error('Project not found', 404);
|
||
}
|
||
Response::json($doc);
|
||
}
|
||
|
||
if ($method === 'PUT') {
|
||
$body = Response::body();
|
||
if (!isset($body['revision']) || !is_int($body['revision']) || !is_array($body['project'] ?? null)) {
|
||
Response::error('Body must contain integer "revision" and object "project"', 422);
|
||
}
|
||
// Asked after whether the project is there for them: a 413 would tell a stranger it is.
|
||
if ($store->load($id) === null) {
|
||
Response::error('Project not found', 404);
|
||
}
|
||
$fits((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) - projectBytes($id));
|
||
try {
|
||
$result = $store->save($id, $body['revision'], $body['project']);
|
||
} catch (StaleRevisionException $e) {
|
||
Response::error('Project was modified elsewhere', 409, ['revision' => $e->currentRevision]);
|
||
}
|
||
if ($result === null) {
|
||
Response::error('Project not found', 404);
|
||
}
|
||
Response::json(['revision' => $result['revision'], 'updatedAt' => $result['updatedAt']]);
|
||
}
|
||
|
||
if ($method === 'DELETE') {
|
||
try {
|
||
$deleted = $store->delete($id);
|
||
} catch (NotOwnerException $e) {
|
||
Response::error($e->getMessage(), 403);
|
||
}
|
||
if (!$deleted) {
|
||
Response::error('Project not found', 404);
|
||
}
|
||
$snapshots->deleteAll($id);
|
||
$releases->takeDown($id);
|
||
$demos->takeDown($id);
|
||
Response::noContent();
|
||
}
|
||
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
/**
|
||
* The snapshots of one project (#231). Whoever may load the project may see, make, restore and
|
||
* delete its snapshots; for everybody else the project, and with it this route, is not there.
|
||
*
|
||
* A snapshot copies the project, and so does restoring one (the stand it replaces is kept), so both
|
||
* count against the quota with the project's size.
|
||
*
|
||
* @param list<string> $parts
|
||
* @param Closure(int):void $fits
|
||
*/
|
||
function routeSnapshots(array $parts, string $method, Store $store, SnapshotStore $snapshots, string $id, Closure $fits): never
|
||
{
|
||
$doc = $store->load($id);
|
||
if ($doc === null) {
|
||
Response::error('Project not found', 404);
|
||
}
|
||
|
||
if (count($parts) === 3) {
|
||
if ($method === 'GET') {
|
||
Response::json($snapshots->list($id));
|
||
}
|
||
if ($method === 'POST') {
|
||
$name = snapshotName(Response::body());
|
||
$fits(projectBytes($id));
|
||
Response::json($snapshots->create($id, $doc, $name === '' ? gmdate('Y-m-d H:i') . ' UTC' : $name), 201);
|
||
}
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
$snapshotId = $parts[3];
|
||
if (!Store::isValidId($snapshotId) || count($parts) > 5) {
|
||
Response::error('Not found', 404);
|
||
}
|
||
$snapshot = $snapshots->load($id, $snapshotId);
|
||
if ($snapshot === null) {
|
||
Response::error('Snapshot not found', 404);
|
||
}
|
||
|
||
if (count($parts) === 4) {
|
||
if ($method !== 'DELETE') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
$snapshots->delete($id, $snapshotId);
|
||
Response::noContent();
|
||
}
|
||
|
||
if ($parts[4] !== 'restore') {
|
||
Response::error('Not found', 404);
|
||
}
|
||
if ($method !== 'POST') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
$body = Response::body();
|
||
if (!isset($body['revision']) || !is_int($body['revision'])) {
|
||
Response::error('Body must contain integer "revision"', 422);
|
||
}
|
||
$fits(projectBytes($id));
|
||
// The same guard a save has: a tab that is behind must not throw away what another one saved.
|
||
if ($body['revision'] !== $doc['revision']) {
|
||
Response::error('Project was modified elsewhere', 409, ['revision' => $doc['revision']]);
|
||
}
|
||
// The stand being replaced is kept first, so a restore can itself be taken back.
|
||
$before = $snapshots->create($id, $doc, mb_substr('Before restoring ' . $snapshot['name'], 0, 120));
|
||
try {
|
||
$result = $store->save($id, $doc['revision'], $snapshot['project']);
|
||
} catch (StaleRevisionException $e) {
|
||
$snapshots->delete($id, $before['id']);
|
||
Response::error('Project was modified elsewhere', 409, ['revision' => $e->currentRevision]);
|
||
}
|
||
if ($result === null) {
|
||
$snapshots->delete($id, $before['id']);
|
||
Response::error('Project not found', 404);
|
||
}
|
||
Response::json(['revision' => $result['revision'], 'updatedAt' => $result['updatedAt'], 'before' => $before]);
|
||
}
|
||
|
||
/**
|
||
* The released copy of one project (#266), or its demo copy (#428). Whoever may load the project may
|
||
* publish it, update it and take it down; publishing copies the project as stored, not what a tab
|
||
* has not saved yet.
|
||
*/
|
||
function routeRelease(
|
||
string $method,
|
||
Store $store,
|
||
ReleaseStore $releases,
|
||
string $id,
|
||
string $none = 'Project is not released',
|
||
): never {
|
||
$doc = $store->load($id);
|
||
if ($doc === null) {
|
||
Response::error('Project not found', 404);
|
||
}
|
||
if ($method === 'GET') {
|
||
Response::json($releases->of($id));
|
||
}
|
||
if ($method === 'POST') {
|
||
Response::json($releases->publish($doc));
|
||
}
|
||
if ($method === 'DELETE') {
|
||
if (!$releases->takeDown($id)) {
|
||
Response::error($none, 404);
|
||
}
|
||
Response::noContent();
|
||
}
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
/**
|
||
* What is open without a sign-in: a released game (#266) and a demo copy (#428), each found by its
|
||
* token. The demos are listed as well, for a guest to pick one.
|
||
*/
|
||
function routeCopies(array $parts, string $method, ReleaseStore $copies, string $missing, bool $listed): never
|
||
{
|
||
if ($method !== 'GET' || count($parts) > 2 || (count($parts) === 1 && !$listed)) {
|
||
Response::error('Not found', 404);
|
||
}
|
||
if (count($parts) === 1) {
|
||
Response::json($copies->list());
|
||
}
|
||
$copy = $copies->load($parts[1]);
|
||
if ($copy === null) {
|
||
Response::error($missing, 404);
|
||
}
|
||
// The copy goes out under the token, not the project's own id: that one is nobody's business.
|
||
$project = ['id' => $copy['token']] + $copy['project'];
|
||
Response::json(['name' => $copy['name'], 'publishedAt' => $copy['publishedAt'], 'project' => $project]);
|
||
}
|
||
|
||
function snapshotName(array $body): string
|
||
{
|
||
$name = trim((string) ($body['name'] ?? ''));
|
||
if (mb_strlen($name) > 120) {
|
||
Response::error('Snapshot name must be at most 120 characters', 422);
|
||
}
|
||
return $name;
|
||
}
|
||
|
||
function agentTokens(): AgentTokens
|
||
{
|
||
return new AgentTokens(DATA_DIR . '/agent-tokens', DATA_DIR . '/agent-tickets');
|
||
}
|
||
|
||
/**
|
||
* Whether the request is the MCP relay's (#429): it carries the key that lies in `data/` for both
|
||
* sides. Nobody else can read that folder, nginx serves none of it.
|
||
*/
|
||
function fromRelay(): bool
|
||
{
|
||
$path = DATA_DIR . '/mcp-relay.key';
|
||
$key = is_file($path) ? trim((string) file_get_contents($path)) : '';
|
||
$given = (string) ($_SERVER['HTTP_X_RELAY_KEY'] ?? '');
|
||
return strlen($key) >= 32 && hash_equals($key, $given);
|
||
}
|
||
|
||
/**
|
||
* The relay asks who is behind an agent's token or a tab's ticket. A wrong token is a wrong password
|
||
* to the brake, counted against the agent's address, not the relay's.
|
||
*
|
||
* @param list<string> $parts
|
||
*/
|
||
function routeRelay(array $parts, string $method, Auth $auth): never
|
||
{
|
||
if (!fromRelay() || count($parts) !== 2) {
|
||
Response::error('Not found', 404);
|
||
}
|
||
if ($method !== 'POST') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
$body = Response::body();
|
||
$tokens = agentTokens();
|
||
if ($parts[1] === 'verify') {
|
||
$throttle = new LoginThrottle(DATA_DIR . '/sign-ins');
|
||
$address = (string) ($body['address'] ?? '');
|
||
refuseWhileThrottled($throttle, $address);
|
||
$user = $tokens->verify((string) ($body['token'] ?? ''));
|
||
if ($user === null) {
|
||
countFailure($throttle, $address);
|
||
Response::error('Unknown or expired agent token', 401);
|
||
}
|
||
Response::json(['user' => $user, 'role' => $auth->role($user)]);
|
||
}
|
||
if ($parts[1] === 'redeem') {
|
||
$user = $tokens->redeemTicket((string) ($body['ticket'] ?? ''));
|
||
if ($user === null) {
|
||
Response::error('Unknown or used ticket', 401);
|
||
}
|
||
Response::json(['user' => $user, 'role' => $auth->role($user)]);
|
||
}
|
||
Response::error('Not found', 404);
|
||
}
|
||
|
||
/** @param list<string> $parts */
|
||
function routeAgentTokens(array $parts, string $method, string $user): never
|
||
{
|
||
$tokens = agentTokens();
|
||
if (count($parts) === 1) {
|
||
if ($method === 'GET') {
|
||
Response::json($tokens->listOf($user));
|
||
}
|
||
if ($method === 'POST') {
|
||
$body = Response::body();
|
||
$name = (string) ($body['name'] ?? '');
|
||
$days = $body['days'] ?? AgentTokens::DEFAULT_DAYS;
|
||
if (!AgentTokens::isValidName($name)) {
|
||
Response::error('A token name is 1–60 characters', 422);
|
||
}
|
||
if (!is_int($days) || $days < 1 || $days > AgentTokens::MAX_DAYS) {
|
||
Response::error('A token lasts 1–' . AgentTokens::MAX_DAYS . ' days', 422);
|
||
}
|
||
try {
|
||
Response::json($tokens->create($user, $name, $days), 201);
|
||
} catch (TooManyAgentTokensException $e) {
|
||
Response::error($e->getMessage(), 409);
|
||
}
|
||
}
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
if ($method !== 'DELETE') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
if (!$tokens->revoke($user, $parts[1])) {
|
||
Response::error('Agent token not found', 404);
|
||
}
|
||
Response::noContent();
|
||
}
|
||
|
||
/** @param list<string> $parts */
|
||
function routeAgent(array $parts, string $method, string $user): never
|
||
{
|
||
if (($parts[1] ?? '') !== 'ticket' || count($parts) !== 2) {
|
||
Response::error('Not found', 404);
|
||
}
|
||
if ($method !== 'POST') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
Response::json(['ticket' => agentTokens()->issueTicket($user), 'expiresIn' => AgentTokens::TICKET_SECONDS]);
|
||
}
|
||
|
||
/** @param list<string> $parts */
|
||
function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never
|
||
{
|
||
$actor = $auth->requireAdmin();
|
||
$projects = new Store(DATA_DIR . '/projects', $actor, true);
|
||
$shown = static function (string $name) use ($users): array {
|
||
foreach ($users->accounts() as $account) {
|
||
if (strcasecmp($account['username'], $name) === 0) {
|
||
return $account;
|
||
}
|
||
}
|
||
throw new RuntimeException("Account {$name} vanished");
|
||
};
|
||
|
||
if (count($parts) === 1) {
|
||
if ($method === 'GET') {
|
||
Response::json(array_map(
|
||
static fn(array $a): array => $a + ['projects' => $projects->listOf($a['username'])],
|
||
$users->accounts(),
|
||
));
|
||
}
|
||
if ($method === 'POST') {
|
||
$body = Response::body();
|
||
$name = (string) ($body['username'] ?? '');
|
||
$password = (string) ($body['password'] ?? '');
|
||
$role = (string) ($body['role'] ?? 'user');
|
||
if (!UserStore::isValidName($name)) {
|
||
Response::error('A user name is 2–32 letters, digits, dots, dashes or underscores', 422);
|
||
}
|
||
if (!UserStore::isValidPassword($password)) {
|
||
Response::error('The password needs at least ' . UserStore::MIN_PASSWORD_LENGTH . ' characters', 422);
|
||
}
|
||
if (!UserStore::isValidRole($role)) {
|
||
Response::error('Unknown role', 422);
|
||
}
|
||
if ($users->exists($name)) {
|
||
Response::error("The name {$name} is taken", 409);
|
||
}
|
||
$users->put($name, $password, $role);
|
||
Response::json($shown($name), 201);
|
||
}
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
$target = $parts[1];
|
||
if (!$users->exists($target)) {
|
||
Response::error('User not found', 404);
|
||
}
|
||
$role = $users->role($target);
|
||
|
||
if ($method === 'PATCH') {
|
||
$body = Response::body();
|
||
$newName = array_key_exists('username', $body) ? (string) $body['username'] : null;
|
||
$newRole = array_key_exists('role', $body) ? (string) $body['role'] : null;
|
||
$password = array_key_exists('password', $body) ? (string) $body['password'] : null;
|
||
// Everything is checked before anything is written: half an edit is worse than none.
|
||
if ($newName !== null && $newName !== $target) {
|
||
$refusal = AccountRules::renameRefusal($actor, $target);
|
||
if ($refusal !== null) {
|
||
Response::error($refusal, 422);
|
||
}
|
||
if (!UserStore::isValidName($newName)) {
|
||
Response::error('A user name is 2–32 letters, digits, dots, dashes or underscores', 422);
|
||
}
|
||
if (strcasecmp($newName, $target) !== 0 && $users->exists($newName)) {
|
||
Response::error("The name {$newName} is taken", 409);
|
||
}
|
||
}
|
||
if ($newRole !== null) {
|
||
if (!UserStore::isValidRole($newRole)) {
|
||
Response::error('Unknown role', 422);
|
||
}
|
||
$refusal = AccountRules::roleRefusal($actor, $target, $role, $newRole, $users->adminCount());
|
||
if ($refusal !== null) {
|
||
Response::error($refusal, 422);
|
||
}
|
||
}
|
||
if ($password !== null && !UserStore::isValidPassword($password)) {
|
||
Response::error('The password needs at least ' . UserStore::MIN_PASSWORD_LENGTH . ' characters', 422);
|
||
}
|
||
|
||
if ($password !== null) {
|
||
$auth->resetPassword($target, $password);
|
||
agentTokens()->endAllOf($target);
|
||
}
|
||
if ($newRole !== null && $newRole !== $role) {
|
||
$users->setRole($target, $newRole);
|
||
}
|
||
$name = $shown($target)['username'];
|
||
if ($newName !== null && $newName !== $name) {
|
||
$users->rename($name, $newName);
|
||
$projects->transfer($name, $newName);
|
||
$auth->endSessionsOf($name);
|
||
agentTokens()->endAllOf($name);
|
||
$name = $newName;
|
||
}
|
||
Response::json($shown($name));
|
||
}
|
||
|
||
if ($method === 'DELETE') {
|
||
$refusal = AccountRules::deleteRefusal($actor, $target, $role, $users->adminCount());
|
||
if ($refusal !== null) {
|
||
Response::error($refusal, 422);
|
||
}
|
||
$refusal = AccountRules::ownerRefusal($target, count($projects->listOf($shown($target)['username'])));
|
||
if ($refusal !== null) {
|
||
Response::error($refusal, 409);
|
||
}
|
||
$users->remove($target);
|
||
$auth->endSessionsOf($target);
|
||
agentTokens()->endAllOf($target);
|
||
Response::noContent();
|
||
}
|
||
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
/**
|
||
* Images, fonts and sounds, stored under the hash of their bytes in a folder per account (#344).
|
||
* Everybody signed in uploads into their own folder and lists their own files and nobody's; an
|
||
* administrator lists every file. Deleting one is for its owner or an administrator, and refused
|
||
* while a project of any account still uses it. What an administrator offers as demo material
|
||
* (#428) is in every list.
|
||
*
|
||
* @param list<string> $parts
|
||
* @param class-string<RuntimeException> $invalid what the store throws for bytes it cannot take
|
||
* @param Closure(int):void $fits
|
||
*/
|
||
function routeFiles(
|
||
array $parts,
|
||
string $method,
|
||
string $user,
|
||
bool $isAdmin,
|
||
UserStore $users,
|
||
HashedFileStore $store,
|
||
int $maxBytes,
|
||
string $noun,
|
||
string $invalid,
|
||
Closure $fits,
|
||
): never {
|
||
$kind = $parts[0];
|
||
$demos = new DemoFiles(DATA_DIR . '/demo-files.json');
|
||
if (count($parts) === 1) {
|
||
if ($method === 'GET') {
|
||
Response::json(filesOffered($store, $kind, $user, $isAdmin, $users));
|
||
}
|
||
if ($method === 'POST') {
|
||
$tooLarge = "{$noun} must be at most " . ($maxBytes / 1024 / 1024) . ' MB';
|
||
$name = (string) ($_GET['name'] ?? '');
|
||
$fits((int) ($_SERVER['CONTENT_LENGTH'] ?? 0));
|
||
try {
|
||
// A video is too big for memory and goes from the request straight to disk.
|
||
if ($store instanceof VideoStore) {
|
||
$in = fopen('php://input', 'rb');
|
||
$convert = ($_GET['convert'] ?? '1') !== '0';
|
||
Response::json($store->storeStream($in, $maxBytes, $name, $users->folderOf($user, true), $convert), 201);
|
||
}
|
||
$bytes = Response::rawBody();
|
||
if (strlen($bytes) > $maxBytes) {
|
||
Response::error($tooLarge, 413);
|
||
}
|
||
Response::json($store->store($bytes, $name, $users->folderOf($user, true)), 201);
|
||
} catch (VideoTooLargeException) {
|
||
Response::error($tooLarge, 413);
|
||
} catch (RuntimeException $e) {
|
||
if (!$e instanceof $invalid) {
|
||
throw $e;
|
||
}
|
||
Response::error($e->getMessage(), 422);
|
||
}
|
||
}
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
// A video kept without being converted is converted later, at its owner's word.
|
||
if ($store instanceof VideoStore && $method === 'POST' && end($parts) === 'convert') {
|
||
$file = implode('/', array_slice($parts, 1, -1));
|
||
$folder = HashedFileStore::folderOf($file);
|
||
if (!$isAdmin && ($folder === '' || $folder !== $users->folderOf($user))) {
|
||
Response::error('Only its owner may convert this video', 403);
|
||
}
|
||
$record = $store->convert($file);
|
||
if ($record === null) {
|
||
Response::error("{$noun} not found", 404);
|
||
}
|
||
Response::json($record);
|
||
}
|
||
|
||
if ($method === 'PATCH') {
|
||
$file = implode('/', array_slice($parts, 1));
|
||
if (!$isAdmin) {
|
||
Response::error('Administrators only', 403);
|
||
}
|
||
if (!$store->exists($file)) {
|
||
Response::error("{$noun} not found", 404);
|
||
}
|
||
$body = Response::body();
|
||
if (!is_bool($body['demo'] ?? null)) {
|
||
Response::error('Body must contain boolean "demo"', 422);
|
||
}
|
||
$demos->set($kind, $file, $body['demo']);
|
||
Response::noContent();
|
||
}
|
||
|
||
if ($method === 'DELETE') {
|
||
$file = implode('/', array_slice($parts, 1));
|
||
// Asked before whether there is one: somebody else's folder tells a stranger nothing.
|
||
$folder = HashedFileStore::folderOf($file);
|
||
if (!$isAdmin && ($folder === '' || $folder !== $users->folderOf($user))) {
|
||
Response::error('Only its owner may delete this file', 403);
|
||
}
|
||
if (!$store->exists($file)) {
|
||
Response::error("{$noun} not found", 404);
|
||
}
|
||
// Everybody may have it in a project by now; whether that ends is the administrators' call.
|
||
if (!$isAdmin && $demos->has($kind, $file)) {
|
||
Response::error('Offered to everybody as demo material: only an administrator can take that back', 409);
|
||
}
|
||
$uses = (new FileUsage(DATA_DIR))->of($file);
|
||
if ($uses !== []) {
|
||
Response::error(FileUsage::refusal($uses, $user, $isAdmin), 409);
|
||
}
|
||
$store->delete($file);
|
||
$demos->set($kind, $file, false);
|
||
Response::noContent();
|
||
}
|
||
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
/**
|
||
* What a picker of `$kind` lists: the files of the one asking and nobody's, an administrator's every
|
||
* file (#344), and everything offered as demo material (#428), with `demo` saying which those are.
|
||
* Signed out (`$user` null) it is the demo material alone, without who uploaded it.
|
||
*
|
||
* @return list<array<string,mixed>>
|
||
*/
|
||
function filesOffered(HashedFileStore $store, string $kind, ?string $user, bool $isAdmin, UserStore $users): array
|
||
{
|
||
$demo = (new DemoFiles(DATA_DIR . '/demo-files.json'))->of($kind);
|
||
$owners = $users->folderOwners();
|
||
$items = $user === null ? [] : $store->list($users->folderOf($user), $isAdmin, $owners);
|
||
if ($demo !== [] && !$isAdmin) {
|
||
$listed = array_column($items, 'file');
|
||
$hidden = $user === null ? ['owner' => null] : [];
|
||
foreach ($store->list('', true, $owners) as $item) {
|
||
if (in_array($item['file'], $demo, true) && !in_array($item['file'], $listed, true)) {
|
||
$items[] = ['deletable' => false, ...$hidden] + $item;
|
||
}
|
||
}
|
||
}
|
||
return array_map(static fn(array $item): array => $item + ['demo' => in_array($item['file'], $demo, true)], $items);
|
||
}
|
||
|
||
function fileStore(string $kind): HashedFileStore
|
||
{
|
||
return match ($kind) {
|
||
'textures' => new TextureStore(DATA_DIR . '/textures'),
|
||
'fonts' => new FontStore(DATA_DIR . '/fonts'),
|
||
'sounds' => new SoundStore(DATA_DIR . '/sounds'),
|
||
'videos' => new VideoStore(DATA_DIR . '/videos'),
|
||
};
|
||
}
|
||
|
||
/** @param list<string> $parts */
|
||
function routePixels(array $parts, string $method): never
|
||
{
|
||
if (count($parts) !== 1 || $method !== 'POST') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
$store = new PixelStore(DATA_DIR . '/pixels');
|
||
$bytes = Response::rawBody();
|
||
if (strlen($bytes) > PixelStore::MAX_BYTES) {
|
||
Response::error('A cel is at most ' . PixelStore::MAX_BYTES . ' bytes', 413);
|
||
}
|
||
try {
|
||
Response::json($store->store($bytes), 201);
|
||
} catch (InvalidPixelsException $e) {
|
||
Response::error($e->getMessage(), 422);
|
||
}
|
||
}
|
||
|
||
/** @param list<string> $parts */
|
||
function routePlugins(array $parts, string $method): never
|
||
{
|
||
$store = new PluginStore(DATA_DIR . '/plugins');
|
||
|
||
if (count($parts) === 1) {
|
||
if ($method !== 'GET') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
Response::json($store->list());
|
||
}
|
||
|
||
if ($method !== 'GET') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
$source = $store->read($parts[1]);
|
||
if ($source === null) {
|
||
Response::error('Plugin not found', 404);
|
||
}
|
||
// Every plugin file is handed out: the project says which of them the editor runs (#119),
|
||
// and the server has no way of knowing which project the asking tab has open.
|
||
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.
|
||
header('Cache-Control: no-cache');
|
||
echo $source;
|
||
exit;
|
||
}
|
||
|
||
try {
|
||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||
$route = trim((string) ($_GET['r'] ?? ''), '/');
|
||
$parts = $route === '' ? [] : explode('/', $route);
|
||
|
||
$site = (string) ($_SERVER['HTTP_SEC_FETCH_SITE'] ?? '');
|
||
if ($method !== 'GET' && $method !== 'HEAD' && ($site === 'cross-site' || $site === 'same-site')) {
|
||
Response::error('Requests from other sites are refused', 403);
|
||
}
|
||
|
||
$maxBody = ($parts[0] ?? '') === 'videos' ? MAX_VIDEO_BYTES : MAX_BODY_BYTES;
|
||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $maxBody) {
|
||
Response::error('Request body too large', 413);
|
||
}
|
||
|
||
// A project reaches deepest, `projects/{id}/snapshots/{snapshotId}/restore`; a stored file is
|
||
// `textures/{folder}/{file}`, a video to convert `videos/{folder}/{file}/convert`; everything
|
||
// else stops at two parts.
|
||
$depth = match ($parts[0] ?? '') {
|
||
'projects' => 5,
|
||
'videos' => 4,
|
||
'textures', 'fonts', 'sounds' => 3,
|
||
default => 2,
|
||
};
|
||
if (count($parts) < 1 || count($parts) > $depth) {
|
||
Response::error('Not found', 404);
|
||
}
|
||
|
||
$users = new UserStore(DATA_DIR . '/users.json');
|
||
$auth = new Auth($users, new SessionStore(DATA_DIR . '/sessions'));
|
||
if ($parts[0] === 'auth') {
|
||
routeAuth($parts, $method, $auth);
|
||
}
|
||
if ($parts[0] === 'users') {
|
||
routeUsers($parts, $method, $auth, $users);
|
||
}
|
||
if ($parts[0] === 'releases') {
|
||
routeCopies($parts, $method, new ReleaseStore(DATA_DIR . '/releases'), 'Game not found', false);
|
||
}
|
||
if ($parts[0] === 'demos') {
|
||
routeCopies($parts, $method, new ReleaseStore(DATA_DIR . '/demos'), 'Demo not found', true);
|
||
}
|
||
if ($parts[0] === 'agent' && in_array($parts[1] ?? '', ['verify', 'redeem'], true)) {
|
||
routeRelay($parts, $method, $auth);
|
||
}
|
||
// A guest's editor runs the plugins a demo switches on (#428); they are the server's own code,
|
||
// put there by its administrator, and only to be read.
|
||
if ($parts[0] === 'plugins' && $method === 'GET') {
|
||
routePlugins($parts, $method);
|
||
}
|
||
// A guest's pickers list the demo material without a session (#428), and nothing else.
|
||
if (in_array($parts[0], DemoFiles::KINDS, true) && count($parts) === 1 && $method === 'GET' && $auth->user() === null) {
|
||
Response::json(filesOffered(fileStore($parts[0]), $parts[0], null, false, $users));
|
||
}
|
||
// Being signed in is the price of admission for everything below, so an uploaded file cannot be
|
||
// dropped in by a stranger.
|
||
$user = $auth->require();
|
||
$isAdmin = $auth->role($user) === 'admin';
|
||
$fits = static function (int $adding) use ($user, $isAdmin, $users): void {
|
||
if ($isAdmin) {
|
||
return;
|
||
}
|
||
$refusal = (new Quota(DATA_DIR, QUOTA_BYTES))->refusal($user, $users->folderOf($user), $adding);
|
||
if ($refusal !== null) {
|
||
Response::error($refusal, 413);
|
||
}
|
||
};
|
||
|
||
match ($parts[0]) {
|
||
'projects' => routeProjects($parts, $method, $user, $isAdmin, $fits),
|
||
'textures' => routeFiles(
|
||
$parts,
|
||
$method,
|
||
$user,
|
||
$isAdmin,
|
||
$users,
|
||
fileStore('textures'),
|
||
MAX_TEXTURE_BYTES,
|
||
'Image',
|
||
InvalidImageException::class,
|
||
$fits,
|
||
),
|
||
'fonts' => routeFiles(
|
||
$parts,
|
||
$method,
|
||
$user,
|
||
$isAdmin,
|
||
$users,
|
||
fileStore('fonts'),
|
||
MAX_FONT_BYTES,
|
||
'Font',
|
||
InvalidFontException::class,
|
||
$fits,
|
||
),
|
||
'sounds' => routeFiles(
|
||
$parts,
|
||
$method,
|
||
$user,
|
||
$isAdmin,
|
||
$users,
|
||
fileStore('sounds'),
|
||
MAX_SOUND_BYTES,
|
||
'Sound',
|
||
InvalidSoundException::class,
|
||
$fits,
|
||
),
|
||
'videos' => routeFiles(
|
||
$parts,
|
||
$method,
|
||
$user,
|
||
$isAdmin,
|
||
$users,
|
||
fileStore('videos'),
|
||
MAX_VIDEO_BYTES,
|
||
'Video',
|
||
InvalidVideoException::class,
|
||
$fits,
|
||
),
|
||
'pixels' => routePixels($parts, $method),
|
||
'plugins' => routePlugins($parts, $method),
|
||
'agent-tokens' => routeAgentTokens($parts, $method, $user),
|
||
'agent' => routeAgent($parts, $method, $user),
|
||
default => Response::error('Not found', 404),
|
||
};
|
||
} catch (Throwable $e) {
|
||
error_log('[feuerwerk api] ' . $e->getMessage());
|
||
Response::error('Internal server error', 500);
|
||
}
|