355 lines
13 KiB
PHP
355 lines
13 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} (null while nobody is signed in)
|
||
* POST ?r=auth/login {username,password} → {user} | 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.
|
||
*
|
||
* 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
|
||
*
|
||
* 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
|
||
*
|
||
* 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 and sounds are served straight from `data/textures/`, `data/fonts/` and
|
||
* `data/sounds/` as static files; the API only writes them.
|
||
*/
|
||
|
||
require __DIR__ . '/lib/Auth.php';
|
||
require __DIR__ . '/lib/FontStore.php';
|
||
require __DIR__ . '/lib/OriginalName.php';
|
||
require __DIR__ . '/lib/PluginStore.php';
|
||
require __DIR__ . '/lib/Response.php';
|
||
require __DIR__ . '/lib/SessionStore.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.
|
||
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<string> $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<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 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);
|
||
}
|
||
|
||
$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);
|
||
}
|