Share a link that plays the game without the editor (#266)
A project could only be played inside the editor, behind the login.
To show a game to someone else there was no way short of an account.
A release freezes the saved project under a random token and serves
it on a page that holds nothing but the game view.
- Menu -> Release... opens a dialog with the state of the release,
the link with Copy link and Open, Publish or Update release, and
Take down behind a confirmation.
- Publish saves first and refuses while anything is unsaved, like
snapshots. Updating keeps the link. Take down kills it, and a new
Publish hands out a new token.
- The link is play.html?game=<32 hex>. The page needs no session,
paints Theme.colors.background, zooms with Auto and waits for
"Click to play" so that sound and keyboard focus are allowed.
- The game always starts in the start scene. Persistent variables
are stored per link in the browser. Plugins are not loaded, and the
dialog warns about them and about a project without a scene.
- GET ?r=releases/{token} is the only route besides auth/* that works
without a login. The public answer carries the token as project.id,
so the project id stays hidden. Release files are written with 0640
and nginx does not serve them directly (measured 403).
- Starting, the frame and Load scene moved into runtime/GameRun.ts.
Play in the editor and the play page run through the same code.
- WorldViewport moved to src/viewport/ and no longer needs the
EditorState. Pointer forwarding (gamePointer.ts) and setting up
textures, fonts and sounds (projectFiles.ts) exist only once.
- Vite builds two pages. The play page loads 4 kB of its own code
plus the shared chunk (Pixi, runtime, drawing, 653 kB) and Rapier,
without the editor.
Found on the way, not fixed here: data/projects/<id>.json answers 200
without a login because the files are 0644.
Tests:
- ./check.sh: 194 files, 2808 tests, exit 0.
- tests/php/releases.test.php: 20 checks, also added to CI.
- New e2e module 60 with 10 checks, played in a browser without a
session. Module 46 counts the menu entries and was updated.
- Full e2e run: 1572 checks passed.
- Counter-checks: 3 unit, 2 PHP and 3 browser mutations all fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
94c1055895
commit
4194f3e150
42 files changed
+1600
-246
No files matched your search
+59
-1
@@ -31,7 +31,7 @@ declare(strict_types=1);
|
||||
* 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 (its snapshots go with it)
|
||||
* DELETE ?r=projects/{id} → 204 (its snapshots and its release go with it)
|
||||
*
|
||||
* GET ?r=projects/{id}/snapshots → [{id,name,revision,createdAt}], newest first
|
||||
* POST ?r=projects/{id}/snapshots {name?} → 201 {id,name,revision,createdAt}
|
||||
@@ -40,6 +40,15 @@ declare(strict_types=1);
|
||||
* (a snapshot copies the project as stored; restoring keeps the
|
||||
* stand it replaces as the snapshot `before`, #231)
|
||||
*
|
||||
* GET ?r=projects/{id}/release → {token,revision,publishedAt} | null
|
||||
* POST ?r=projects/{id}/release → {token,revision,publishedAt} (publishes the stored project)
|
||||
* DELETE ?r=projects/{id}/release → 204 | 404
|
||||
* (one link per project, kept when publishing again, #266)
|
||||
*
|
||||
* Released games are the one thing open to everybody, the token being the key:
|
||||
*
|
||||
* GET ?r=releases/{token} → {name,publishedAt,project} | 404
|
||||
*
|
||||
* 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
|
||||
@@ -70,6 +79,7 @@ require __DIR__ . '/lib/FontStore.php';
|
||||
require __DIR__ . '/lib/OriginalName.php';
|
||||
require __DIR__ . '/lib/PixelStore.php';
|
||||
require __DIR__ . '/lib/PluginStore.php';
|
||||
require __DIR__ . '/lib/ReleaseStore.php';
|
||||
require __DIR__ . '/lib/Response.php';
|
||||
require __DIR__ . '/lib/SessionStore.php';
|
||||
require __DIR__ . '/lib/SnapshotStore.php';
|
||||
@@ -180,6 +190,10 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
|
||||
if (($parts[2] ?? null) === 'snapshots') {
|
||||
routeSnapshots($parts, $method, $store, $snapshots, $id);
|
||||
}
|
||||
$releases = new ReleaseStore(dirname(__DIR__) . '/data/releases');
|
||||
if (($parts[2] ?? null) === 'release' && count($parts) === 3) {
|
||||
routeRelease($method, $store, $releases, $id);
|
||||
}
|
||||
if (count($parts) > 2) {
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
@@ -213,6 +227,7 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
|
||||
Response::error('Project not found', 404);
|
||||
}
|
||||
$snapshots->deleteAll($id);
|
||||
$releases->takeDown($id);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
@@ -289,6 +304,46 @@ function routeSnapshots(array $parts, string $method, Store $store, SnapshotStor
|
||||
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(dirname(__DIR__) . '/data/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'] ?? ''));
|
||||
@@ -581,6 +636,9 @@ try {
|
||||
if ($parts[0] === 'users') {
|
||||
routeUsers($parts, $method, $auth, $users);
|
||||
}
|
||||
if ($parts[0] === 'releases') {
|
||||
routeReleases($parts, $method);
|
||||
}
|
||||
// Images, fonts, sounds, cels 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();
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Released games (#266): a frozen copy of a project that anybody with the link can play.
|
||||
*
|
||||
* data/releases/{token}.json { token, projectId, name, revision, publishedAt, project }
|
||||
* data/releases/projects/{projectId}.json { token } which link a project has
|
||||
*
|
||||
* One link per project: publishing again replaces the copy and keeps the token, so a link handed out
|
||||
* stays good. The token is 128 random bits and is the only secret, so both kinds of file are written
|
||||
* 0640 – the web server serves `data/` as static files and must not hand them out by name.
|
||||
*
|
||||
* Like the snapshots, the store checks nothing about who asks: the router reaches the project half
|
||||
* only after `Store::load` found the project for that user.
|
||||
*/
|
||||
final class ReleaseStore
|
||||
{
|
||||
public function __construct(private readonly string $dir)
|
||||
{
|
||||
}
|
||||
|
||||
public static function isValidToken(string $token): bool
|
||||
{
|
||||
return (bool) preg_match('/^[a-f0-9]{32}$/', $token);
|
||||
}
|
||||
|
||||
/** @return array{token:string,revision:int,publishedAt:string}|null */
|
||||
public function of(string $projectId): ?array
|
||||
{
|
||||
$token = $this->tokenOf($projectId);
|
||||
$release = $token === null ? null : $this->load($token);
|
||||
return $release === null ? null : self::summary($release);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a stored project document into the project's release, under the token it already has.
|
||||
*
|
||||
* @param array{id:string,name:string,revision:int,project:array} $doc
|
||||
* @return array{token:string,revision:int,publishedAt:string}
|
||||
*/
|
||||
public function publish(array $doc): array
|
||||
{
|
||||
$token = $this->tokenOf($doc['id']);
|
||||
if ($token === null) {
|
||||
do {
|
||||
$token = bin2hex(random_bytes(16));
|
||||
} while (file_exists($this->path($token)));
|
||||
}
|
||||
$release = [
|
||||
'token' => $token,
|
||||
'projectId' => $doc['id'],
|
||||
'name' => $doc['name'],
|
||||
'revision' => $doc['revision'],
|
||||
'publishedAt' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
'project' => $doc['project'],
|
||||
];
|
||||
// The copy first, the pointer to it second: a crash in between leaves an unused file, never
|
||||
// a project pointing at nothing.
|
||||
$this->write($this->path($token), $release);
|
||||
$this->write($this->indexPath($doc['id']), ['token' => $token]);
|
||||
return self::summary($release);
|
||||
}
|
||||
|
||||
/** @return array{token:string,projectId:string,name:string,revision:int,publishedAt:string,project:array}|null */
|
||||
public function load(string $token): ?array
|
||||
{
|
||||
if (!self::isValidToken($token)) {
|
||||
return null;
|
||||
}
|
||||
$release = $this->read($this->path($token));
|
||||
if ($release === null || !is_array($release['project'] ?? null) || !is_string($release['projectId'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'token' => $token,
|
||||
'projectId' => $release['projectId'],
|
||||
'name' => is_string($release['name'] ?? null) ? $release['name'] : '',
|
||||
'revision' => (int) ($release['revision'] ?? 0),
|
||||
'publishedAt' => is_string($release['publishedAt'] ?? null) ? $release['publishedAt'] : '',
|
||||
'project' => $release['project'],
|
||||
];
|
||||
}
|
||||
|
||||
/** Makes the link dead. False when the project had none. */
|
||||
public function takeDown(string $projectId): bool
|
||||
{
|
||||
$token = $this->tokenOf($projectId);
|
||||
$index = $this->indexPath($projectId);
|
||||
if (is_file($index)) {
|
||||
unlink($index);
|
||||
}
|
||||
if ($token === null) {
|
||||
return false;
|
||||
}
|
||||
$path = $this->path($token);
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function tokenOf(string $projectId): ?string
|
||||
{
|
||||
$token = $this->read($this->indexPath($projectId))['token'] ?? null;
|
||||
return is_string($token) && self::isValidToken($token) ? $token : null;
|
||||
}
|
||||
|
||||
/** @return array{token:string,revision:int,publishedAt:string} */
|
||||
private static function summary(array $release): array
|
||||
{
|
||||
return [
|
||||
'token' => $release['token'],
|
||||
'revision' => $release['revision'],
|
||||
'publishedAt' => $release['publishedAt'],
|
||||
];
|
||||
}
|
||||
|
||||
private function path(string $token): string
|
||||
{
|
||||
return $this->dir . '/' . $token . '.json';
|
||||
}
|
||||
|
||||
private function indexPath(string $projectId): string
|
||||
{
|
||||
return $this->dir . '/projects/' . $projectId . '.json';
|
||||
}
|
||||
|
||||
private function read(string $path): ?array
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
$raw = file_get_contents($path);
|
||||
$data = $raw === false ? null : json_decode($raw, true);
|
||||
return is_array($data) ? $data : null;
|
||||
}
|
||||
|
||||
private function write(string $path, array $data): void
|
||||
{
|
||||
$folder = dirname($path);
|
||||
if (!is_dir($folder) && !mkdir($folder, 0775, true) && !is_dir($folder)) {
|
||||
throw new RuntimeException("Cannot create release directory {$folder}");
|
||||
}
|
||||
$json = json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Cannot encode release');
|
||||
}
|
||||
$tmp = $path . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
||||
if (file_put_contents($tmp, $json, LOCK_EX) === false) {
|
||||
@unlink($tmp);
|
||||
throw new RuntimeException('Cannot write release file');
|
||||
}
|
||||
chmod($tmp, 0640);
|
||||
if (!rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
throw new RuntimeException('Cannot write release file');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user