$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. Response::json(['user' => $auth->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]); } 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): never { $store = new Store(dirname(__DIR__) . '/data/projects', $owner); 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 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 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: since #119 the project says which of them the editor runs, // 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); } $auth = new Auth( new UserStore(dirname(__DIR__) . '/data/users.json'), new SessionStore(dirname(__DIR__) . '/data/sessions'), ); if ($parts[0] === 'auth') { routeAuth($parts, $method, $auth); } // Images, fonts, sounds 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), 'textures' => routeTextures($parts, $method), 'fonts' => routeFonts($parts, $method), 'sounds' => routeSounds($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); }