$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 $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); } 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); } Response::noContent(); } Response::error('Method not allowed', 405); } /** @param list $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 $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 $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 $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 $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 $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); if (count($parts) < 1 || count($parts) > 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); } // 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); }