From c89952114cf266d1e60bab457107cf5317abb116 Mon Sep 17 00:00:00 2001 From: Daniel McCoy Stephenson Date: Mon, 5 Oct 2026 20:53:26 -0600 Subject: [PATCH] Add share-preview tags to the landing page and robots.txt on the API host og:* and twitter:* tags let a shared play. link unfurl. robots.txt allows all and is answered on the API host only, so it never shadows a static game's own file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BZQMN1M5hQKfUpPwDkMjqd --- CHANGELOG.md | 8 ++++++++ src/arcade/landing.py | 24 ++++++++++++++++++++++-- src/arcade/server.py | 5 +++++ tests/test_landing.py | 24 ++++++++++++++++++++++++ tests/test_server.py | 18 ++++++++++++++++++ 5 files changed, 77 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37ecb31..e3e1431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ All notable changes to this project are documented in this file. The format is b ### Added +- Share-preview metadata on the landing page: `og:type`, `og:site_name`, `og:title`, + `og:description`, `og:url` (`https://play./`) and a `summary` `twitter:card` with its title + and description, so a shared link to `play.` unfurls with a title and description. No + `og:image` is set, as the repository has no image to point at. +- `GET /robots.txt` on `play.`: `User-agent: *` / `Allow: /` as `text/plain`. Like + `/version.json`, it is answered on the API host only, never on a game's host, so it cannot shadow + a static game's own `/robots.txt`. + - Optional `canonical:` registry field: the game's main public page, as an https URL. When it is set, every HTML response for the game, on its slug host and on its aliases, carries `Link: ; rel="canonical"`. Search engines then index one address for the game instead of diff --git a/src/arcade/landing.py b/src/arcade/landing.py index 39fcd0c..ce0c2d5 100644 --- a/src/arcade/landing.py +++ b/src/arcade/landing.py @@ -16,6 +16,16 @@ CSP = "default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'" +# Already HTML-escaped; shared by , the description and the share-preview +# tags (og:* and twitter:*). There is no og:image: the repository holds no image +# to point at, and the CSP keeps the page itself free of any. +TITLE = "Play — browser games by Daniel McCoy Stephenson" +DESCRIPTION = "Browser games by Daniel McCoy Stephenson, served by arcade. Nothing to install." + +# Served at /robots.txt on the API host only. A game's host never answers it: +# a static game may ship its own robots.txt and must not be shadowed. +ROBOTS = "User-agent: *\nAllow: /\n" + _STYLE = """ :root { color-scheme: light dark; --bg: #f6f7f9; --fg: #1a1c20; --muted: #5b616b; --card: #ffffff; --line: #dde1e7; --accent: #3758d6; --accent-fg: #ffffff; } @@ -77,9 +87,17 @@ def render(registry, domain, portalUrl, deployed=lambda slug: True): <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> -<title>Play — browser games by Daniel McCoy Stephenson - +%(title)s + + + + + + + + + @@ -99,6 +117,8 @@ def render(registry, domain, portalUrl, deployed=lambda slug: True): """ % { "domain": escape(domain), + "title": TITLE, + "description": DESCRIPTION, "style": _STYLE, "portal": portal, "portalLabel": portalLabel, diff --git a/src/arcade/server.py b/src/arcade/server.py index ebecb13..f5f30d2 100644 --- a/src/arcade/server.py +++ b/src/arcade/server.py @@ -405,6 +405,11 @@ def _api(self, path, read): if read and path == "/version.json": self._version() return + if read and path == "/robots.txt": + # API host only, like /version.json: on a game's host the path + # belongs to the game. + self._send(200, landing.ROBOTS.encode("utf-8"), headers=(("Cache-Control", "public, max-age=3600"),)) + return if read and path == "/api/games": registry = arcade.registry.registry self._json(200, {"games": [self._describe(game) for game in registry]}, public=True) diff --git a/tests/test_landing.py b/tests/test_landing.py index cd4d841..e5de7f0 100644 --- a/tests/test_landing.py +++ b/tests/test_landing.py @@ -24,3 +24,27 @@ 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 + + +def test_the_page_carries_share_preview_tags_on_its_own_domain(): + page = landing.render(games(("rps", "RPS")), "play.example.com", "https://example.com/play").decode("utf-8") + for tag in ( + '', + '', + '', + '', + '', + '' in page diff --git a/tests/test_server.py b/tests/test_server.py index 4db187e..bcd0a75 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -107,6 +107,24 @@ def test_version_json_on_a_game_host_belongs_to_the_game(arcade): assert request(arcade, "GET", host, "/version.json")[0].status == 404, host +def test_the_api_host_serves_robots_txt_allowing_all(arcade): + for method in ("GET", "HEAD"): + response, data = request(arcade, method, API, "/robots.txt") + assert response.status == 200 + assert response.getheader("Content-Type") == "text/plain; charset=utf-8" + assert data == (b"User-agent: *\nAllow: /\n" if method == "GET" else b"") + + +def test_robots_txt_on_a_game_host_belongs_to_the_game(arcade): + site = dict(_site("0.1"), **{"robots.txt": b"User-agent: *\nDisallow: /secret\n"}) + assert upload(arcade, slug="rps", version="0.1", body=tarBundle(site))[0].status == 201 + response, data = request(arcade, "GET", "rps." + DOMAIN, "/robots.txt") + assert (response.status, data) == (200, b"User-agent: *\nDisallow: /secret\n") + upload(arcade) + for host in (TIDEWATER, "tidewater.example.org", "nope." + DOMAIN, "localhost"): + assert request(arcade, "GET", host, "/robots.txt")[0].status == 404, host + + def test_unknown_hosts_are_404_and_still_isolated(arcade): for host in ("example.com", "nope." + DOMAIN, "a.b." + DOMAIN, ""): response, _ = request(arcade, "GET", host, "/")