Files
feuerwerk/api/index.php
T
2026-09-17 19:00:03 +02:00

269 lines
9.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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=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/FontStore.php';
require __DIR__ . '/lib/OriginalName.php';
require __DIR__ . '/lib/PluginStore.php';
require __DIR__ . '/lib/Response.php';
require __DIR__ . '/lib/SoundStore.php';
require __DIR__ . '/lib/Store.php';
require __DIR__ . '/lib/TextureStore.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 routeProjects(array $parts, string $method): never
{
$store = new Store(dirname(__DIR__) . '/data/projects');
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
{
if ($method !== 'GET') {
Response::error('Method not allowed', 405);
}
$store = new PluginStore(dirname(__DIR__) . '/data/plugins');
if (count($parts) === 1) {
Response::json($store->list());
}
$source = $store->read($parts[1]);
if ($source === null) {
Response::error('Plugin not found', 404);
}
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);
}
match ($parts[0]) {
'projects' => routeProjects($parts, $method),
'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);
}