$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); } agentTokens()->endAllOf($user); 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, ?string $address = null): void { $throttle->fail($address ?? 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, ?string $address = null): void { $wait = $throttle->waitFor($address ?? 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); } $demos = new ReleaseStore(DATA_DIR . '/demos'); if (($parts[2] ?? null) === 'demo' && count($parts) === 3) { // What guests get to see is the administrators' call (#428). if (!$seesAll) { Response::error('Administrators only', 403); } routeRelease($method, $store, $demos, $id, 'Project has no demo copy'); } 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); $demos->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), or its demo copy (#428). 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, string $none = 'Project is not released', ): 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($none, 404); } Response::noContent(); } Response::error('Method not allowed', 405); } /** * What is open without a sign-in: a released game (#266) and a demo copy (#428), each found by its * token. The demos are listed as well, for a guest to pick one. */ function routeCopies(array $parts, string $method, ReleaseStore $copies, string $missing, bool $listed): never { if ($method !== 'GET' || count($parts) > 2 || (count($parts) === 1 && !$listed)) { Response::error('Not found', 404); } if (count($parts) === 1) { Response::json($copies->list()); } $copy = $copies->load($parts[1]); if ($copy === null) { Response::error($missing, 404); } // The copy goes out under the token, not the project's own id: that one is nobody's business. $project = ['id' => $copy['token']] + $copy['project']; Response::json(['name' => $copy['name'], 'publishedAt' => $copy['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; } function agentTokens(): AgentTokens { return new AgentTokens(DATA_DIR . '/agent-tokens', DATA_DIR . '/agent-tickets'); } /** * Whether the request is the MCP relay's (#429): it carries the key that lies in `data/` for both * sides. Nobody else can read that folder, nginx serves none of it. */ function fromRelay(): bool { $path = DATA_DIR . '/mcp-relay.key'; $key = is_file($path) ? trim((string) file_get_contents($path)) : ''; $given = (string) ($_SERVER['HTTP_X_RELAY_KEY'] ?? ''); return strlen($key) >= 32 && hash_equals($key, $given); } /** * The relay asks who is behind an agent's token or a tab's ticket. A wrong token is a wrong password * to the brake, counted against the agent's address, not the relay's. * * @param list $parts */ function routeRelay(array $parts, string $method, Auth $auth): never { if (!fromRelay() || count($parts) !== 2) { Response::error('Not found', 404); } if ($method !== 'POST') { Response::error('Method not allowed', 405); } $body = Response::body(); $tokens = agentTokens(); if ($parts[1] === 'verify') { $throttle = new LoginThrottle(DATA_DIR . '/sign-ins'); $address = (string) ($body['address'] ?? ''); refuseWhileThrottled($throttle, $address); $user = $tokens->verify((string) ($body['token'] ?? '')); if ($user === null) { countFailure($throttle, $address); Response::error('Unknown or expired agent token', 401); } Response::json(['user' => $user, 'role' => $auth->role($user)]); } if ($parts[1] === 'redeem') { $user = $tokens->redeemTicket((string) ($body['ticket'] ?? '')); if ($user === null) { Response::error('Unknown or used ticket', 401); } Response::json(['user' => $user, 'role' => $auth->role($user)]); } Response::error('Not found', 404); } /** @param list $parts */ function routeAgentTokens(array $parts, string $method, string $user): never { $tokens = agentTokens(); if (count($parts) === 1) { if ($method === 'GET') { Response::json($tokens->listOf($user)); } if ($method === 'POST') { $body = Response::body(); $name = (string) ($body['name'] ?? ''); $days = $body['days'] ?? AgentTokens::DEFAULT_DAYS; if (!AgentTokens::isValidName($name)) { Response::error('A token name is 1–60 characters', 422); } if (!is_int($days) || $days < 1 || $days > AgentTokens::MAX_DAYS) { Response::error('A token lasts 1–' . AgentTokens::MAX_DAYS . ' days', 422); } try { Response::json($tokens->create($user, $name, $days), 201); } catch (TooManyAgentTokensException $e) { Response::error($e->getMessage(), 409); } } Response::error('Method not allowed', 405); } if ($method !== 'DELETE') { Response::error('Method not allowed', 405); } if (!$tokens->revoke($user, $parts[1])) { Response::error('Agent token not found', 404); } Response::noContent(); } /** @param list $parts */ function routeAgent(array $parts, string $method, string $user): never { if (($parts[1] ?? '') !== 'ticket' || count($parts) !== 2) { Response::error('Not found', 404); } if ($method !== 'POST') { Response::error('Method not allowed', 405); } Response::json(['ticket' => agentTokens()->issueTicket($user), 'expiresIn' => AgentTokens::TICKET_SECONDS]); } /** @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); agentTokens()->endAllOf($target); } 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); agentTokens()->endAllOf($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); agentTokens()->endAllOf($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. What an administrator offers as demo material * (#428) is in every list. * * @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 { $kind = $parts[0]; $demos = new DemoFiles(DATA_DIR . '/demo-files.json'); if (count($parts) === 1) { if ($method === 'GET') { Response::json(filesOffered($store, $kind, $user, $isAdmin, $users)); } 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 === 'PATCH') { $file = implode('/', array_slice($parts, 1)); if (!$isAdmin) { Response::error('Administrators only', 403); } if (!$store->exists($file)) { Response::error("{$noun} not found", 404); } $body = Response::body(); if (!is_bool($body['demo'] ?? null)) { Response::error('Body must contain boolean "demo"', 422); } $demos->set($kind, $file, $body['demo']); Response::noContent(); } 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); } // Everybody may have it in a project by now; whether that ends is the administrators' call. if (!$isAdmin && $demos->has($kind, $file)) { Response::error('Offered to everybody as demo material: only an administrator can take that back', 409); } $uses = (new FileUsage(DATA_DIR))->of($file); if ($uses !== []) { Response::error(FileUsage::refusal($uses, $user, $isAdmin), 409); } $store->delete($file); $demos->set($kind, $file, false); Response::noContent(); } Response::error('Method not allowed', 405); } /** * What a picker of `$kind` lists: the files of the one asking and nobody's, an administrator's every * file (#344), and everything offered as demo material (#428), with `demo` saying which those are. * Signed out (`$user` null) it is the demo material alone, without who uploaded it. * * @return list> */ function filesOffered(HashedFileStore $store, string $kind, ?string $user, bool $isAdmin, UserStore $users): array { $demo = (new DemoFiles(DATA_DIR . '/demo-files.json'))->of($kind); $owners = $users->folderOwners(); $items = $user === null ? [] : $store->list($users->folderOf($user), $isAdmin, $owners); if ($demo !== [] && !$isAdmin) { $listed = array_column($items, 'file'); $hidden = $user === null ? ['owner' => null] : []; foreach ($store->list('', true, $owners) as $item) { if (in_array($item['file'], $demo, true) && !in_array($item['file'], $listed, true)) { $items[] = ['deletable' => false, ...$hidden] + $item; } } } return array_map(static fn(array $item): array => $item + ['demo' => in_array($item['file'], $demo, true)], $items); } function fileStore(string $kind): HashedFileStore { return match ($kind) { 'textures' => new TextureStore(DATA_DIR . '/textures'), 'fonts' => new FontStore(DATA_DIR . '/fonts'), 'sounds' => new SoundStore(DATA_DIR . '/sounds'), 'videos' => new VideoStore(DATA_DIR . '/videos'), }; } /** @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') { routeCopies($parts, $method, new ReleaseStore(DATA_DIR . '/releases'), 'Game not found', false); } if ($parts[0] === 'demos') { routeCopies($parts, $method, new ReleaseStore(DATA_DIR . '/demos'), 'Demo not found', true); } if ($parts[0] === 'agent' && in_array($parts[1] ?? '', ['verify', 'redeem'], true)) { routeRelay($parts, $method, $auth); } // A guest's editor runs the plugins a demo switches on (#428); they are the server's own code, // put there by its administrator, and only to be read. if ($parts[0] === 'plugins' && $method === 'GET') { routePlugins($parts, $method); } // A guest's pickers list the demo material without a session (#428), and nothing else. if (in_array($parts[0], DemoFiles::KINDS, true) && count($parts) === 1 && $method === 'GET' && $auth->user() === null) { Response::json(filesOffered(fileStore($parts[0]), $parts[0], null, false, $users)); } // 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'; $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, fileStore('textures'), MAX_TEXTURE_BYTES, 'Image', InvalidImageException::class, $fits, ), 'fonts' => routeFiles( $parts, $method, $user, $isAdmin, $users, fileStore('fonts'), MAX_FONT_BYTES, 'Font', InvalidFontException::class, $fits, ), 'sounds' => routeFiles( $parts, $method, $user, $isAdmin, $users, fileStore('sounds'), MAX_SOUND_BYTES, 'Sound', InvalidSoundException::class, $fits, ), 'videos' => routeFiles( $parts, $method, $user, $isAdmin, $users, fileStore('videos'), MAX_VIDEO_BYTES, 'Video', InvalidVideoException::class, $fits, ), 'pixels' => routePixels($parts, $method), 'plugins' => routePlugins($parts, $method), 'agent-tokens' => routeAgentTokens($parts, $method, $user), 'agent' => routeAgent($parts, $method, $user), default => Response::error('Not found', 404), }; } catch (Throwable $e) { error_log('[feuerwerk api] ' . $e->getMessage()); Response::error('Internal server error', 500); }