Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@
All notable changes to this project are documented in this file. The format is based on
[Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the project uses semantic versioning.

## [0.6.0] - 2026-10-03

### Added

- A landing page at `https://play.<base>/` (and `/index.html`) instead of the 302 to the portal: a
small static HTML page generated from the registry, listing every game by title and linking it to
`https://<slug>.play.<base>/` (a game with nothing deployed is listed as "Coming soon", unlinked),
with a prominent "Browse all games" link to `ARCADE_LANDING_URL`. No script and no external request
(enforced by its CSP), light and dark, phone-first; `Cache-Control: public, max-age=60`.
- `ARCADE_LANDING_MODE`: `page` (default) or `redirect`, which keeps the old 302 to
`ARCADE_LANDING_URL`. Any other value refuses to start. The API is unchanged.

## [0.5.0] - 2026-10-02

### Changed
Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ database.

| Host | What it serves |
|---|---|
| `play.<base>` | the upload API (below); `/` redirects to the portal |
| `play.<base>` | the upload API (below); `/` is a landing page listing every game (see below) |
| `<slug>.play.<base>` | the game registered under that slug |
| an alias from the registry | the same game, at an older hostname, so that origin's saves are kept |
| anything else | 404 |
Expand Down Expand Up @@ -86,6 +86,16 @@ under `require-corp`. Set `isolation: on` for a build that needs `SharedArrayBuf
Emscripten with pthreads. A static upload refuses links, absolute paths, `..` and empty segments,
caps the file count at 10,000, and is subject to the same byte cap as a tak upload.

## The landing page

`https://play.<base>/` (and `/index.html`) is a small HTML page generated from the registry on each
request: every game by title, linked to `https://<slug>.play.<base>/` (a game with nothing deployed is
listed as "Coming soon", unlinked), and a prominent "Browse all games" link to `ARCADE_LANDING_URL`.
It has no script and makes no external request (its `Content-Security-Policy` is
`default-src 'none'; style-src 'unsafe-inline'; …`), follows the visitor's light/dark setting, is laid
out for a phone first, and is sent with `Cache-Control: public, max-age=60`. Set
`ARCADE_LANDING_MODE=redirect` to answer `/` with the old 302 to `ARCADE_LANDING_URL` instead.

## The API (on `play.<base>`)

| Method | Path | Does |
Expand Down Expand Up @@ -152,7 +162,8 @@ change that removes that container's router, or Traefik will see two routers for
| `ARCADE_DOMAIN` | `play.danielstephenson.dev` |
| `ARCADE_DATA` | `/data` (back this up; losing it is recoverable by each game re-running its deploy) |
| `ARCADE_REGISTRY` | `/config/games.yaml` |
| `ARCADE_LANDING_URL` | `https://danielstephenson.dev/play` |
| `ARCADE_LANDING_URL` | `https://danielstephenson.dev/play` (the portal: the landing page's "Browse all games" link, or the redirect target) |
| `ARCADE_LANDING_MODE` | `page` (serve the landing page at `/`) or `redirect` (a 302 to `ARCADE_LANDING_URL`, as before 0.6.0) |
| `ARCADE_MAX_UPLOAD_BYTES` | `67108864` (64 MiB, compressed and unpacked) |
| `ARCADE_KEEP_VERSIONS` | `5` |
| `ARCADE_HOST` / `ARCADE_PORT` | `0.0.0.0` / `8080` in the image |
Expand Down
2 changes: 1 addition & 1 deletion src/arcade/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# @author Daniel McCoy Stephenson
"""arcade: one static host for every browser game (Stephenson-Software RFC 0006)."""

__version__ = "0.5.0"
__version__ = "0.6.0"
108 changes: 108 additions & 0 deletions src/arcade/landing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# @author Daniel McCoy Stephenson
"""The landing page at https://play.<base>/: every game in the registry.

One small, self-contained HTML document, generated from the registry on each
request (the registry is reloaded when its file changes, so the page follows
it): no script, no stylesheet, font or image fetched from anywhere, light and
dark from the visitor's own setting, and laid out for a phone first. Its
Content-Security-Policy (CSP below) forbids every external request, so a
mistake here cannot add one silently.

A game with nothing deployed yet is listed without a link, since its address
would only answer 404.
"""

import html

CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"

_STYLE = """
:root { color-scheme: light dark; --bg: #f6f7f9; --fg: #1a1c20; --muted: #5b616b; --card: #ffffff;
--line: #dde1e7; --accent: #3758d6; --accent-fg: #ffffff; }
@media (prefers-color-scheme: dark) {
:root { --bg: #121418; --fg: #eceef2; --muted: #a2a8b3; --card: #1b1e24; --line: #2c3139;
--accent: #7d98ff; --accent-fg: #0d1020; }
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg);
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 44rem; margin: 0 auto; padding: 2rem 16px 3rem; }
h1 { font-size: 2rem; line-height: 1.2; margin: 0 0 .5rem; }
p { margin: 0 0 1.25rem; color: var(--muted); }
.browse { display: inline-block; margin: 0 0 2rem; padding: .8rem 1.2rem; border-radius: .6rem;
background: var(--accent); color: var(--accent-fg); font-weight: 600; text-decoration: none; }
.browse:focus-visible, li a:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; }
h2 { font-size: 1.15rem; margin: 0 0 .75rem; }
ul { list-style: none; margin: 0; padding: 0; display: grid; gap: .6rem; }
li { background: var(--card); border: 1px solid var(--line); border-radius: .6rem; min-width: 0; }
li a, li span.soon { display: block; padding: .8rem 1rem; color: inherit; text-decoration: none;
overflow-wrap: anywhere; }
li a:hover .title { text-decoration: underline; }
.title { display: block; font-weight: 600; color: var(--accent); }
.host { display: block; font-size: .85rem; color: var(--muted); }
span.soon .title { color: var(--fg); }
footer { margin-top: 2.5rem; font-size: .85rem; color: var(--muted); }
footer a { color: inherit; }
"""


def gameUrl(game, domain):
return "https://%s.%s/" % (game.slug, domain)


def render(registry, domain, portalUrl, deployed=lambda slug: True):
"""The page as UTF-8 bytes. `deployed(slug)` says whether a game has a
current version (only those are linked)."""
escape = html.escape
games = sorted(registry, key=lambda game: ((game.title or game.slug).lower(), game.slug))
items = []
for game in games:
title = escape(game.title or game.slug)
host = escape("%s.%s" % (game.slug, domain))
if deployed(game.slug):
items.append(
'<li><a href="%s"><span class="title">%s</span><span class="host">%s</span></a></li>'
% (escape(gameUrl(game, domain)), title, host)
)
else:
items.append(
'<li><span class="soon"><span class="title">%s</span>'
'<span class="host">Coming soon</span></span></li>' % title
)
listing = "\n".join(items) if items else "<li><span class=\"soon\">No games yet.</span></li>"
portal = escape(portalUrl)
portalLabel = escape(portalUrl.split("://", 1)[-1].rstrip("/"))
document = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Play &mdash; browser games by Daniel McCoy Stephenson</title>
<meta name="description" content="Browser games by Daniel McCoy Stephenson, served by arcade. Nothing to install.">
<link rel="canonical" href="https://%(domain)s/">
<link rel="icon" href="data:,">
<style>%(style)s</style>
</head>
<body>
<main>
<h1>Play</h1>
<p>Browser games by Daniel McCoy Stephenson. Nothing to install: pick one and it runs in this tab.</p>
<a class="browse" href="%(portal)s">Browse all games &rarr; %(portalLabel)s</a>
<h2>Games served here (%(count)d)</h2>
<ul>
%(listing)s
</ul>
<footer>Served by <a href="https://github.com/Stephenson-Software/arcade">arcade</a>. Likes, scores and
details for each game are on <a href="%(portal)s">%(portalLabel)s</a>.</footer>
</main>
</body>
</html>
""" % {
"domain": escape(domain),
"style": _STYLE,
"portal": portal,
"portalLabel": portalLabel,
"count": len(games),
"listing": listing,
}
return document.encode("utf-8")
40 changes: 37 additions & 3 deletions src/arcade/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

One process answers two kinds of host:

play.<base> the upload API, and / redirecting to the portal
play.<base> the upload API, and / a page listing every game
(or, with ARCADE_LANDING_MODE=redirect, a 302 to
the portal)
<slug>.play.<base> a game, served the way tak.web.serve serves one
<alias> a game's old hostname (registry `aliases`)

Expand Down Expand Up @@ -31,7 +33,7 @@
import threading
from urllib.parse import parse_qs, unquote, urlparse

from arcade import __version__, bundle, registry as registryModule, routers
from arcade import __version__, bundle, landing, registry as registryModule, routers
from arcade.store import VERSION_PATTERN, NoSuchVersion, Store, VersionExists

INDEX_PATHS = ("/", "/play", "/play/", "/index.html")
Expand All @@ -47,6 +49,7 @@
# nothing; COOP and COEP, which give the game its isolation, are unchanged.
("Cross-Origin-Resource-Policy", "cross-origin"),
)
LANDING_MODES = ("page", "redirect")
DEFAULT_MAX_UPLOAD_BYTES = 64 * 1024 * 1024
# User agents that are not a person opening a game: crawlers, link-preview
# fetchers and scripts. Their page loads are not counted as plays.
Expand Down Expand Up @@ -87,11 +90,18 @@ def __init__(
internalHost="arcade",
traefikService="arcade@docker",
traefikMiddlewares=("secure-headers@file",),
landingMode="page",
):
self.domain = domain.lower().strip(".")
self.dataDirectory = dataDirectory
self.registryPath = registryPath
# The portal: where `/` redirects in "redirect" mode, and what the
# landing page's "Browse all games" links to in "page" mode.
self.landingUrl = landingUrl
landingMode = (landingMode or "page").strip().lower()
if landingMode not in LANDING_MODES:
raise ValueError("ARCADE_LANDING_MODE must be one of %s, not %r" % ("/".join(LANDING_MODES), landingMode))
self.landingMode = landingMode
self.maxUploadBytes = maxUploadBytes
self.keep = keep
# The name other containers reach arcade by. Only a request addressed
Expand All @@ -108,6 +118,7 @@ def fromEnvironment(cls, environ=None):
dataDirectory=environ.get("ARCADE_DATA", "/data"),
registryPath=environ.get("ARCADE_REGISTRY", "/config/games.yaml"),
landingUrl=environ.get("ARCADE_LANDING_URL", "https://danielstephenson.dev/play"),
landingMode=environ.get("ARCADE_LANDING_MODE", "page"),
maxUploadBytes=int(environ.get("ARCADE_MAX_UPLOAD_BYTES", DEFAULT_MAX_UPLOAD_BYTES)),
keep=int(environ.get("ARCADE_KEEP_VERSIONS", "5")),
internalHost=environ.get("ARCADE_INTERNAL_HOST", "arcade"),
Expand Down Expand Up @@ -381,7 +392,7 @@ def _cached(self, data, contentType, etag):

def _api(self, path, read):
if read and path in ("/", "/index.html"):
self._send(302, b"", headers=(("Location", config.landingUrl),))
self._landing()
return
if read and path == "/api/games":
registry = arcade.registry.registry
Expand All @@ -405,6 +416,29 @@ def _api(self, path, read):
return
self._json(404, {"error": "not found"})

def _landing(self):
if config.landingMode == "redirect":
self._send(302, b"", headers=(("Location", config.landingUrl),))
return
body = landing.render(
arcade.registry.registry,
config.domain,
config.landingUrl,
deployed=lambda slug: arcade.store.current(slug) is not None,
)
self._send(
200,
body,
"text/html; charset=utf-8",
headers=(
("Content-Security-Policy", landing.CSP),
("X-Content-Type-Options", "nosniff"),
("Referrer-Policy", "strict-origin-when-cross-origin"),
# Generated from the registry, which can change at any time.
("Cache-Control", "public, max-age=60"),
),
)

def _describe(self, game):
return {
"slug": game.slug,
Expand Down
26 changes: 26 additions & 0 deletions tests/test_landing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
from arcade import landing
from arcade.registry import Game, Registry


def games(*entries):
return Registry([Game(slug, title, "https://github.com/x/" + slug, "0" * 64) for slug, title in entries])


def test_titles_are_escaped_and_sorted():
page = landing.render(games(("zeta", "Zeta"), ("evil", "<script>alert(1)</script>"), ("alpha", "Alpha")),
"play.example.com", "https://example.com/play").decode("utf-8")
assert "<script>alert" not in page
assert "&lt;script&gt;" in page
assert page.index("Alpha") < page.index("Zeta")


def test_an_empty_registry_still_renders():
page = landing.render(Registry([]), "play.example.com", "https://example.com/play").decode("utf-8")
assert "No games yet." in page
assert "(0)" in page


def test_the_csp_forbids_external_requests():
for directive in ("default-src 'none'", "base-uri 'none'", "form-action 'none'"):
assert directive in landing.CSP
assert "http" not in landing.CSP
53 changes: 52 additions & 1 deletion tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,63 @@ def test_unknown_hosts_are_404_and_still_isolated(arcade):
assertIsolated(response)


def test_the_api_host_redirects_to_the_portal(arcade):
def test_the_api_host_serves_a_landing_page_listing_every_game(arcade):
upload(arcade, "tidewater", "1.0.0")
response, data = request(arcade, "GET", API, "/")
assert response.status == 200
assert response.getheader("Content-Type") == "text/html; charset=utf-8"
page = data.decode("utf-8")
# A deployed game is linked at its own host; one with nothing deployed is listed, unlinked.
assert '<a href="https://tidewater.%s/">' % DOMAIN in page
for slug in ("overwinter", "rps", "pthreads"):
assert "https://%s.%s/" % (slug, DOMAIN) not in page
assert page.count("<li>") == 4
assert "Coming soon" in page
assert '<a class="browse" href="https://example.com/play">Browse all games' in page
assert response.getheader("Cache-Control") == "public, max-age=60"
assert "default-src 'none'" in response.getheader("Content-Security-Policy")


def test_the_landing_page_makes_no_external_requests(arcade):
_, data = request(arcade, "GET", API, "/index.html")
page = data.decode("utf-8")
assert "<script" not in page
assert "<img" not in page
assert 'rel="stylesheet"' not in page
assert "@import" not in page and "url(" not in page
# Phone-friendly, and both colour schemes.
assert 'name="viewport"' in page
assert "prefers-color-scheme: dark" in page


def test_the_landing_page_follows_the_registry(arcade):
arcade.registryFile.write_text(registryText([game("tidewater"), game("newgame")]))
os.utime(str(arcade.registryFile), ns=(10**18, 10**18))
arcade.registry.refresh()
_, data = request(arcade, "GET", API, "/")
assert data.decode("utf-8").count("<li>") == 2


def test_head_on_the_landing_page_has_no_body(arcade):
response, data = request(arcade, "HEAD", API, "/")
assert response.status == 200
assert data == b""


def test_redirect_mode_keeps_the_old_302(arcade):
arcade.config.landingMode = "redirect"
response, _ = request(arcade, "GET", API, "/")
assert response.status == 302
assert response.getheader("Location") == "https://example.com/play"


def test_landing_mode_is_validated():
with pytest.raises(ValueError):
Config("d", "/tmp", "/tmp/r", "https://x", landingMode="iframe")
assert Config.fromEnvironment({}).landingMode == "page"
assert Config.fromEnvironment({"ARCADE_LANDING_MODE": "Redirect"}).landingMode == "redirect"


def test_a_registered_game_before_any_deploy(arcade):
response, data = request(arcade, "GET", TIDEWATER, "/")
assert response.status == 404
Expand Down
Loading