The editor gets a name of its own that fits beside Brandmauer, Glutspeicher and Feuerwehr. This is stage 1, 2 and 4 of #422: the name, the URL and the stored state in the browser. Repository, CI runners, containers and the GAME_MAKER_* variables follow in stage 3. - All paths come from SITE_PATH in src/core/site.ts: the API endpoint, the data folders, the play page and the base in vite.config.ts. A later move is one line instead of fifteen. - The window title, the sign-in screen, the playground and the README say Feuerwerk. The package, __feuerwerkPlay, the take worklet and the writing app in Ogg and Matroska follow. - The session cookie is now feuerwerk_session, so everyone signs in once more. - localStorage keys are now feuerwerk.*. moveOldStoredKeys copies the old game-maker.* keys over on the first start, so settings, panel sizes and save games survive. - tools/ci/serve.sh serves the build under /feuerwerk/. The variable GAME_MAKER_BASE_URL keeps its name until stage 3. Outside the repository, nockal.com/feuerwerk/ is a second symlink to the same tree. nginx sends the bare /game-maker/ folder there with a 301. Everything else under /game-maker/ is still served directly, so shared play links and the API keep working. Finding: when nginx redirects a path without a trailing slash or with a query, the Brandmauer answers with its own 307 to the same URL plus a slash, placed after the query. A play link would lose its token that way, which is why only the bare folder is redirected. Tests: - Gate on melektaus-atx: CHECK EXIT 0, 307 files, 4756 tests (4 new in site.test.ts). - Counter-check: when the migration misses the old prefix, 3 of the 4 new tests fail. - e2e 60 under /feuerwerk/: ALL PASSED, 20 checks. - From outside: /feuerwerk/ 200, /game-maker/ 301, an old play link 200. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
742 lines
30 KiB
PHP
742 lines
30 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
|
||
* POST ?r=auth/logout → 204
|
||
* POST ?r=auth/password {currentPassword,newPassword} → 204 | 401 | 422
|
||
* (the only auth route that needs a session; ends the account's
|
||
* other logins, keeps the one asking)
|
||
*
|
||
* 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)
|
||
*
|
||
* 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 the one thing open to everybody, the token being the key:
|
||
*
|
||
* GET ?r=releases/{token} → {name,publishedAt,project} | 404
|
||
*
|
||
* 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 `game-maker-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 or release uses it.
|
||
*
|
||
* 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
|
||
*
|
||
* `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/AtomicFile.php';
|
||
require __DIR__ . '/lib/Auth.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/OriginalName.php';
|
||
require __DIR__ . '/lib/PixelStore.php';
|
||
require __DIR__ . '/lib/PluginStore.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;
|
||
|
||
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] ?? '';
|
||
|
||
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();
|
||
$user = $auth->logIn((string) ($body['username'] ?? ''), (string) ($body['password'] ?? ''));
|
||
if ($user === null) {
|
||
// 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);
|
||
}
|
||
if (!$auth->changePassword($user, (string) ($body['currentPassword'] ?? ''), $new)) {
|
||
Response::error('Wrong password', 401);
|
||
}
|
||
Response::noContent();
|
||
}
|
||
|
||
if ($what === 'logout') {
|
||
if ($method !== 'POST') {
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
$auth->logOut();
|
||
Response::noContent();
|
||
}
|
||
|
||
Response::error('Not found', 404);
|
||
}
|
||
|
||
/** @param list<string> $parts */
|
||
function routeProjects(array $parts, string $method, string $owner, bool $seesAll): never
|
||
{
|
||
$store = new Store(dirname(__DIR__) . '/data/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);
|
||
}
|
||
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(dirname(__DIR__) . '/data/snapshots');
|
||
if (($parts[2] ?? null) === 'snapshots') {
|
||
routeSnapshots($parts, $method, $store, $snapshots, $id);
|
||
}
|
||
$releases = new ReleaseStore(dirname(__DIR__) . '/data/releases');
|
||
if (($parts[2] ?? null) === 'release' && count($parts) === 3) {
|
||
routeRelease($method, $store, $releases, $id);
|
||
}
|
||
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);
|
||
}
|
||
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);
|
||
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.
|
||
*
|
||
* @param list<string> $parts
|
||
*/
|
||
function routeSnapshots(array $parts, string $method, Store $store, SnapshotStore $snapshots, string $id): 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());
|
||
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);
|
||
}
|
||
// 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). 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): 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('Project is not released', 404);
|
||
}
|
||
Response::noContent();
|
||
}
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
/** The one route open without a sign-in: a released game, found by its token. */
|
||
function routeReleases(array $parts, string $method): never
|
||
{
|
||
if ($method !== 'GET' || count($parts) !== 2) {
|
||
Response::error('Not found', 404);
|
||
}
|
||
$release = (new ReleaseStore(dirname(__DIR__) . '/data/releases'))->load($parts[1]);
|
||
if ($release === null) {
|
||
Response::error('Game not found', 404);
|
||
}
|
||
// The copy goes out under the token, not the project's own id: that one is nobody's business.
|
||
$project = ['id' => $release['token']] + $release['project'];
|
||
Response::json(['name' => $release['name'], 'publishedAt' => $release['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;
|
||
}
|
||
|
||
/** @param list<string> $parts */
|
||
function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never
|
||
{
|
||
$actor = $auth->requireAdmin();
|
||
$projects = new Store(dirname(__DIR__) . '/data/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);
|
||
}
|
||
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);
|
||
$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);
|
||
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.
|
||
*
|
||
* @param list<string> $parts
|
||
* @param class-string<RuntimeException> $invalid what the store throws for bytes it cannot take
|
||
*/
|
||
function routeFiles(
|
||
array $parts,
|
||
string $method,
|
||
string $user,
|
||
bool $isAdmin,
|
||
UserStore $users,
|
||
HashedFileStore $store,
|
||
int $maxBytes,
|
||
string $noun,
|
||
string $invalid,
|
||
): never {
|
||
if (count($parts) === 1) {
|
||
if ($method === 'GET') {
|
||
Response::json($store->list($users->folderOf($user), $isAdmin, $users->folderOwners()));
|
||
}
|
||
if ($method === 'POST') {
|
||
$tooLarge = "{$noun} must be at most " . ($maxBytes / 1024 / 1024) . ' MB';
|
||
$name = (string) ($_GET['name'] ?? '');
|
||
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 === '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);
|
||
}
|
||
$uses = (new FileUsage(dirname(__DIR__) . '/data'))->of($file);
|
||
if ($uses !== []) {
|
||
Response::error(FileUsage::refusal($uses, $user, $isAdmin), 409);
|
||
}
|
||
$store->delete($file);
|
||
Response::noContent();
|
||
}
|
||
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
/** @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(dirname(__DIR__) . '/data/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(dirname(__DIR__) . '/data/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);
|
||
|
||
$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(dirname(__DIR__) . '/data/users.json');
|
||
$auth = new Auth($users, new SessionStore(dirname(__DIR__) . '/data/sessions'));
|
||
if ($parts[0] === 'auth') {
|
||
routeAuth($parts, $method, $auth);
|
||
}
|
||
if ($parts[0] === 'users') {
|
||
routeUsers($parts, $method, $auth, $users);
|
||
}
|
||
if ($parts[0] === 'releases') {
|
||
routeReleases($parts, $method);
|
||
}
|
||
// 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';
|
||
$data = dirname(__DIR__) . '/data';
|
||
|
||
match ($parts[0]) {
|
||
'projects' => routeProjects($parts, $method, $user, $isAdmin),
|
||
'textures' => routeFiles(
|
||
$parts,
|
||
$method,
|
||
$user,
|
||
$isAdmin,
|
||
$users,
|
||
new TextureStore("{$data}/textures"),
|
||
MAX_TEXTURE_BYTES,
|
||
'Image',
|
||
InvalidImageException::class,
|
||
),
|
||
'fonts' => routeFiles(
|
||
$parts,
|
||
$method,
|
||
$user,
|
||
$isAdmin,
|
||
$users,
|
||
new FontStore("{$data}/fonts"),
|
||
MAX_FONT_BYTES,
|
||
'Font',
|
||
InvalidFontException::class,
|
||
),
|
||
'sounds' => routeFiles(
|
||
$parts,
|
||
$method,
|
||
$user,
|
||
$isAdmin,
|
||
$users,
|
||
new SoundStore("{$data}/sounds"),
|
||
MAX_SOUND_BYTES,
|
||
'Sound',
|
||
InvalidSoundException::class,
|
||
),
|
||
'videos' => routeFiles(
|
||
$parts,
|
||
$method,
|
||
$user,
|
||
$isAdmin,
|
||
$users,
|
||
new VideoStore("{$data}/videos"),
|
||
MAX_VIDEO_BYTES,
|
||
'Video',
|
||
InvalidVideoException::class,
|
||
),
|
||
'pixels' => routePixels($parts, $method),
|
||
'plugins' => routePlugins($parts, $method),
|
||
default => Response::error('Not found', 404),
|
||
};
|
||
} catch (Throwable $e) {
|
||
error_log('[feuerwerk api] ' . $e->getMessage());
|
||
Response::error('Internal server error', 500);
|
||
}
|