$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); } 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): void { $throttle->fail(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): void { $wait = $throttle->waitFor(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 $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); } 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); 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 $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). 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(DATA_DIR . '/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 $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); } 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 $parts * @param class-string $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 { 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'] ?? ''); $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 === '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(DATA_DIR))->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 $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 $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') { 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 = DATA_DIR; $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, new TextureStore("{$data}/textures"), MAX_TEXTURE_BYTES, 'Image', InvalidImageException::class, $fits, ), 'fonts' => routeFiles( $parts, $method, $user, $isAdmin, $users, new FontStore("{$data}/fonts"), MAX_FONT_BYTES, 'Font', InvalidFontException::class, $fits, ), 'sounds' => routeFiles( $parts, $method, $user, $isAdmin, $users, new SoundStore("{$data}/sounds"), MAX_SOUND_BYTES, 'Sound', InvalidSoundException::class, $fits, ), 'videos' => routeFiles( $parts, $method, $user, $isAdmin, $users, new VideoStore("{$data}/videos"), MAX_VIDEO_BYTES, 'Video', InvalidVideoException::class, $fits, ), '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); }