From 13861a2ef01de0c9d3ce3e4959015202f4e7f9fe Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Sat, 3 Oct 2026 14:12:03 -0600 Subject: [PATCH] arcade 0.6.0: a landing page at play./ instead of the 302 The page is generated from the registry: every game linked to its .play URL, a "Browse all games" link to ARCADE_LANDING_URL, no script or external request (CSP), light/dark, phone-first. ARCADE_LANDING_MODE=redirect keeps the old 302. The API is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0116e9DMUhtNXM5bHUdMhDAn --- CHANGELOG.md | 12 +++++ README.md | 15 +++++- src/arcade/__init__.py | 2 +- src/arcade/landing.py | 108 +++++++++++++++++++++++++++++++++++++++++ src/arcade/server.py | 40 +++++++++++++-- tests/test_landing.py | 26 ++++++++++ tests/test_server.py | 53 +++++++++++++++++++- 7 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 src/arcade/landing.py create mode 100644 tests/test_landing.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f6966a..7052638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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./` (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://.play./` (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 diff --git a/README.md b/README.md index 015daad..326579c 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ database. | Host | What it serves | |---|---| -| `play.` | the upload API (below); `/` redirects to the portal | +| `play.` | the upload API (below); `/` is a landing page listing every game (see below) | | `.play.` | 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 | @@ -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./` (and `/index.html`) is a small HTML page generated from the registry on each +request: every game by title, linked to `https://.play./` (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.`) | Method | Path | Does | @@ -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 | diff --git a/src/arcade/__init__.py b/src/arcade/__init__.py index 84785b8..a8df5c5 100644 --- a/src/arcade/__init__.py +++ b/src/arcade/__init__.py @@ -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" diff --git a/src/arcade/landing.py b/src/arcade/landing.py new file mode 100644 index 0000000..39fcd0c --- /dev/null +++ b/src/arcade/landing.py @@ -0,0 +1,108 @@ +# @author Daniel McCoy Stephenson +"""The landing page at https://play./: 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( + '
  • %s%s
  • ' + % (escape(gameUrl(game, domain)), title, host) + ) + else: + items.append( + '
  • %s' + 'Coming soon
  • ' % title + ) + listing = "\n".join(items) if items else "
  • No games yet.
  • " + portal = escape(portalUrl) + portalLabel = escape(portalUrl.split("://", 1)[-1].rstrip("/")) + document = """ + + + + +Play — browser games by Daniel McCoy Stephenson + + + + + + +
    +

    Play

    +

    Browser games by Daniel McCoy Stephenson. Nothing to install: pick one and it runs in this tab.

    +Browse all games → %(portalLabel)s +

    Games served here (%(count)d)

    +
      +%(listing)s +
    + +
    + + +""" % { + "domain": escape(domain), + "style": _STYLE, + "portal": portal, + "portalLabel": portalLabel, + "count": len(games), + "listing": listing, + } + return document.encode("utf-8") diff --git a/src/arcade/server.py b/src/arcade/server.py index 0a4790e..de49483 100644 --- a/src/arcade/server.py +++ b/src/arcade/server.py @@ -3,7 +3,9 @@ One process answers two kinds of host: - play. the upload API, and / redirecting to the portal + play. the upload API, and / a page listing every game + (or, with ARCADE_LANDING_MODE=redirect, a 302 to + the portal) .play. a game, served the way tak.web.serve serves one a game's old hostname (registry `aliases`) @@ -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") @@ -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. @@ -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 @@ -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"), @@ -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 @@ -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, diff --git a/tests/test_landing.py b/tests/test_landing.py new file mode 100644 index 0000000..cd4d841 --- /dev/null +++ b/tests/test_landing.py @@ -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", ""), ("alpha", "Alpha")), + "play.example.com", "https://example.com/play").decode("utf-8") + assert "