game-maker / suite (push) Successful in 9m10s
A font asset holds a file and nothing else, the twin of a texture: the server keeps it
content-addressed under data/fonts/{sha256}.{ext}, with the format read from the file's own
four-byte signature (finfo reports fonts inconsistently across libmagic versions). A text
wears one through its ordinary Font property, so overrides, reset, undo and the Properties
node come for free; no font means the editor's own typeface.
The picker lists everything the server holds and renders each row in the face it offers.
The family name is gm-font-<hash16>, never the name inside the file, so two "Regular" cannot
collide. WorldViewport.syncText is the single place that draws it, which covers scene, game
preview and play at once.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
193 lines
6.4 KiB
PHP
193 lines
6.4 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=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,width,height,bytes,updatedAt}]
|
||
* POST ?r=textures raw image bytes → {file,width,height,bytes}
|
||
* DELETE ?r=textures/{file} → 204
|
||
*
|
||
* GET ?r=fonts → [{file,bytes,updatedAt}]
|
||
* POST ?r=fonts raw font bytes → {file,bytes}
|
||
* DELETE ?r=fonts/{file} → 204
|
||
*
|
||
* Stored images and fonts are served straight from `data/textures/` and `data/fonts/` as
|
||
* static files; the API only writes them.
|
||
*/
|
||
|
||
require __DIR__ . '/lib/FontStore.php';
|
||
require __DIR__ . '/lib/Response.php';
|
||
require __DIR__ . '/lib/Store.php';
|
||
require __DIR__ . '/lib/TextureStore.php';
|
||
|
||
const MAX_BODY_BYTES = 8 * 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;
|
||
|
||
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), 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), 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);
|
||
}
|
||
|
||
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),
|
||
default => Response::error('Not found', 404),
|
||
};
|
||
} catch (Throwable $e) {
|
||
error_log('[game-maker api] ' . $e->getMessage());
|
||
Response::error('Internal server error', 500);
|
||
}
|