A project could only be played inside the editor, behind the login.
To show a game to someone else there was no way short of an account.
A release freezes the saved project under a random token and serves
it on a page that holds nothing but the game view.
- Menu -> Release... opens a dialog with the state of the release,
the link with Copy link and Open, Publish or Update release, and
Take down behind a confirmation.
- Publish saves first and refuses while anything is unsaved, like
snapshots. Updating keeps the link. Take down kills it, and a new
Publish hands out a new token.
- The link is play.html?game=<32 hex>. The page needs no session,
paints Theme.colors.background, zooms with Auto and waits for
"Click to play" so that sound and keyboard focus are allowed.
- The game always starts in the start scene. Persistent variables
are stored per link in the browser. Plugins are not loaded, and the
dialog warns about them and about a project without a scene.
- GET ?r=releases/{token} is the only route besides auth/* that works
without a login. The public answer carries the token as project.id,
so the project id stays hidden. Release files are written with 0640
and nginx does not serve them directly (measured 403).
- Starting, the frame and Load scene moved into runtime/GameRun.ts.
Play in the editor and the play page run through the same code.
- WorldViewport moved to src/viewport/ and no longer needs the
EditorState. Pointer forwarding (gamePointer.ts) and setting up
textures, fonts and sounds (projectFiles.ts) exist only once.
- Vite builds two pages. The play page loads 4 kB of its own code
plus the shared chunk (Pixi, runtime, drawing, 653 kB) and Rapier,
without the editor.
Found on the way, not fixed here: data/projects/<id>.json answers 200
without a login because the files are 0644.
Tests:
- ./check.sh: 194 files, 2808 tests, exit 0.
- tests/php/releases.test.php: 20 checks, also added to CI.
- New e2e module 60 with 10 checks, played in a browser without a
session. Module 46 counts the menu entries and was updated.
- Full e2e run: 1572 checks passed.
- Counter-checks: 3 unit, 2 PHP and 3 browser mutations all fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
659 lines
25 KiB
PHP
659 lines
25 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
/**
|
||
* Game Maker 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, saves and deletes it like their own; a save keeps its owner.
|
||
*
|
||
* 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 (its snapshots and its release go with it)
|
||
*
|
||
* 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}]
|
||
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
|
||
* DELETE ?r=textures/{file} → 204
|
||
*
|
||
* GET ?r=fonts → [{file,name,bytes,updatedAt}]
|
||
* POST ?r=fonts&name=x.ttf raw font bytes → {file,name,bytes}
|
||
* DELETE ?r=fonts/{file} → 204
|
||
*
|
||
* GET ?r=sounds → [{file,name,bytes,updatedAt}]
|
||
* POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes}
|
||
* DELETE ?r=sounds/{file} → 204
|
||
*
|
||
* 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}`.
|
||
*
|
||
* Stored images, fonts, sounds and cels are served straight from `data/textures/`, `data/fonts/`,
|
||
* `data/sounds/` and `data/pixels/` as static files; the API only writes them.
|
||
*/
|
||
|
||
require __DIR__ . '/lib/AccountRules.php';
|
||
require __DIR__ . '/lib/Auth.php';
|
||
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';
|
||
|
||
/** Has to clear the largest single upload below, which is a sound; the web server has its own say. */
|
||
const MAX_BODY_BYTES = 20 * 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, four times as high: one music track is worth more than four typefaces. */
|
||
const MAX_SOUND_BYTES = 16 * 1024 * 1024;
|
||
|
||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||
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') {
|
||
if (!$store->delete($id)) {
|
||
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);
|
||
}
|
||
|
||
/** @param list<string> $parts */
|
||
function routeTextures(array $parts, string $method): never
|
||
{
|
||
$store = new TextureStore(dirname(__DIR__) . '/data/textures');
|
||
|
||
if (count($parts) === 1) {
|
||
if ($method === 'GET') {
|
||
Response::json($store->list());
|
||
}
|
||
if ($method === 'POST') {
|
||
$bytes = Response::rawBody();
|
||
if (strlen($bytes) > MAX_TEXTURE_BYTES) {
|
||
Response::error('Image must be at most ' . (MAX_TEXTURE_BYTES / 1024 / 1024) . ' MB', 413);
|
||
}
|
||
try {
|
||
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
|
||
} catch (InvalidImageException $e) {
|
||
Response::error($e->getMessage(), 422);
|
||
}
|
||
}
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
if ($method === 'DELETE') {
|
||
if (!$store->delete($parts[1])) {
|
||
Response::error('Texture not found', 404);
|
||
}
|
||
Response::noContent();
|
||
}
|
||
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
/** @param list<string> $parts */
|
||
function routeFonts(array $parts, string $method): never
|
||
{
|
||
$store = new FontStore(dirname(__DIR__) . '/data/fonts');
|
||
|
||
if (count($parts) === 1) {
|
||
if ($method === 'GET') {
|
||
Response::json($store->list());
|
||
}
|
||
if ($method === 'POST') {
|
||
$bytes = Response::rawBody();
|
||
if (strlen($bytes) > MAX_FONT_BYTES) {
|
||
Response::error('Font must be at most ' . (MAX_FONT_BYTES / 1024 / 1024) . ' MB', 413);
|
||
}
|
||
try {
|
||
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
|
||
} catch (InvalidFontException $e) {
|
||
Response::error($e->getMessage(), 422);
|
||
}
|
||
}
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
if ($method === 'DELETE') {
|
||
if (!$store->delete($parts[1])) {
|
||
Response::error('Font not found', 404);
|
||
}
|
||
Response::noContent();
|
||
}
|
||
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
/** @param list<string> $parts */
|
||
function routeSounds(array $parts, string $method): never
|
||
{
|
||
$store = new SoundStore(dirname(__DIR__) . '/data/sounds');
|
||
|
||
if (count($parts) === 1) {
|
||
if ($method === 'GET') {
|
||
Response::json($store->list());
|
||
}
|
||
if ($method === 'POST') {
|
||
$bytes = Response::rawBody();
|
||
if (strlen($bytes) > MAX_SOUND_BYTES) {
|
||
Response::error('Sound must be at most ' . (MAX_SOUND_BYTES / 1024 / 1024) . ' MB', 413);
|
||
}
|
||
try {
|
||
Response::json($store->store($bytes, (string) ($_GET['name'] ?? '')), 201);
|
||
} catch (InvalidSoundException $e) {
|
||
Response::error($e->getMessage(), 422);
|
||
}
|
||
}
|
||
Response::error('Method not allowed', 405);
|
||
}
|
||
|
||
if ($method === 'DELETE') {
|
||
if (!$store->delete($parts[1])) {
|
||
Response::error('Sound not found', 404);
|
||
}
|
||
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 {
|
||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) {
|
||
Response::error('Request body too large', 413);
|
||
}
|
||
|
||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||
$route = trim((string) ($_GET['r'] ?? ''), '/');
|
||
$parts = $route === '' ? [] : explode('/', $route);
|
||
|
||
// Only a project reaches deeper than two parts: `projects/{id}/snapshots/{snapshotId}/restore`.
|
||
if (count($parts) < 1 || count($parts) > ($parts[0] === 'projects' ? 5 : 2)) {
|
||
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);
|
||
}
|
||
// Images, fonts, sounds, cels and plugins are shared between accounts; being signed in is still the
|
||
// price of admission, so an uploaded file cannot be dropped in by a stranger.
|
||
$user = $auth->require();
|
||
|
||
match ($parts[0]) {
|
||
'projects' => routeProjects($parts, $method, $user, $auth->role($user) === 'admin'),
|
||
'textures' => routeTextures($parts, $method),
|
||
'fonts' => routeFonts($parts, $method),
|
||
'sounds' => routeSounds($parts, $method),
|
||
'pixels' => routePixels($parts, $method),
|
||
'plugins' => routePlugins($parts, $method),
|
||
default => Response::error('Not found', 404),
|
||
};
|
||
} catch (Throwable $e) {
|
||
error_log('[game-maker api] ' . $e->getMessage());
|
||
Response::error('Internal server error', 500);
|
||
}
|