diff --git a/CHANGELOG.md b/CHANGELOG.md index 429207f..37ecb31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ All notable changes to this project are documented in this file. The format is b ### Added +- 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 + splitting it across `.play.`, old alias hosts and the portal page that frames it. + Non-HTML files and games without the field send no Link header. - `GET /version.json` on `play.`: `{"version": ""}` with `Content-Type: application/json` and `Cache-Control: no-store`, so a deploy can be verified by the version it reports. It is answered on the API host only, never on a game's host, so it cannot diff --git a/src/arcade/registry.py b/src/arcade/registry.py index 3152103..8014837 100644 --- a/src/arcade/registry.py +++ b/src/arcade/registry.py @@ -17,6 +17,7 @@ aliases: [tidewater.danielstephenson.dev] # optional, flow list only kind: tak # optional: tak (default) or static isolation: on # optional: on/off (see below) + canonical: https://example.com/play/tidewater # optional (see below) `games: []` is an empty registry. @@ -31,6 +32,12 @@ Cross-Origin-Resource-Policy header (pygbag does) cannot run under require-corp. Turn it on for a static build that needs SharedArrayBuffer (an Emscripten build with pthreads). + +canonical: the game's main public page, as an https URL. A game can be reached +at its slug host, its aliases and the portal page that frames it, so search +engines see the same game at several addresses. When set, every HTML response +for the game carries `Link: ; rel="canonical"`, which names one address +as the original. Leave it out and no Link header is sent. """ import re @@ -41,12 +48,14 @@ r"^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$" ) REPO_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +# An absolute https URL with nothing a Link header would have to escape. +CANONICAL_PATTERN = re.compile(r"^https://[A-Za-z0-9.-]+(/[A-Za-z0-9._~/%-]*)?$") # Labels the service itself uses or that would be confusing as a game. RESERVED_SLUGS = frozenset(("play", "www", "api", "arcade", "admin", "static")) REQUIRED_KEYS = ("slug", "title", "repo", "token_sha256") -OPTIONAL_KEYS = ("owner", "aliases", "kind", "isolation") +OPTIONAL_KEYS = ("owner", "aliases", "kind", "isolation", "canonical") KINDS = ("tak", "static") _SWITCH = {"on": True, "true": True, "yes": True, "off": False, "false": False, "no": False} KNOWN_KEYS = REQUIRED_KEYS + OPTIONAL_KEYS @@ -57,9 +66,11 @@ class RegistryError(ValueError): class Game(object): - __slots__ = ("slug", "title", "repo", "owner", "tokenSha256", "aliases", "kind", "isolation") + __slots__ = ("slug", "title", "repo", "owner", "tokenSha256", "aliases", "kind", "isolation", "canonical") - def __init__(self, slug, title, repo, tokenSha256, owner=None, aliases=(), kind="tak", isolation=None): + def __init__( + self, slug, title, repo, tokenSha256, owner=None, aliases=(), kind="tak", isolation=None, canonical=None + ): self.slug = slug self.title = title self.repo = repo @@ -68,6 +79,7 @@ def __init__(self, slug, title, repo, tokenSha256, owner=None, aliases=(), kind= self.aliases = tuple(aliases) self.kind = kind self.isolation = (kind == "tak") if isolation is None else bool(isolation) + self.canonical = canonical def __repr__(self): return "Game(%r)" % self.slug @@ -251,6 +263,11 @@ def validate(entries, domain=None): "line %d: %r is a tak game, which needs isolation (SharedArrayBuffer carries " "its input); it cannot be turned off" % (lineNumber, slug) ) + canonical = entry.get("canonical") + if canonical is not None and not CANONICAL_PATTERN.match(canonical): + raise RegistryError( + "line %d: canonical for %r must be an https URL, got %r" % (lineNumber, slug, canonical) + ) games.append( Game( slug=slug, @@ -261,6 +278,7 @@ def validate(entries, domain=None): aliases=aliases, kind=kind, isolation=isolation, + canonical=canonical, ) ) return Registry(games) diff --git a/src/arcade/server.py b/src/arcade/server.py index 8296bcf..ebecb13 100644 --- a/src/arcade/server.py +++ b/src/arcade/server.py @@ -231,6 +231,9 @@ def _host(self): # Per response: a static game with isolation off sends no # Cross-Origin headers (RFC 0012); everything else does. isolate = True + # Per response: the game's canonical page, sent as a Link header on + # HTML so search engines index one address for it, not every host. + canonical = None def end_headers(self): if self.isolate: @@ -244,6 +247,8 @@ def _send(self, status, body=b"", contentType="text/plain; charset=utf-8", heade self.send_header("Content-Length", str(len(body))) for name, value in headers: self.send_header(name, value) + if self.canonical and contentType.startswith("text/html"): + self.send_header("Link", '<%s>; rel="canonical"' % self.canonical) self.end_headers() if self.command != "HEAD": self.wfile.write(body) @@ -282,6 +287,7 @@ def _dispatch(self, read): # Reset every request: one handler serves a whole keep-alive # connection, and a proxy may reuse it across hosts. self.isolate = True + self.canonical = None path = self._path() if path == "/healthz" and read: self._text(200, "ok") @@ -315,6 +321,7 @@ def _dispatch(self, read): def _game(self, game, path): self.isolate = game.isolation + self.canonical = game.canonical version = arcade.store.current(game.slug) if version is None: self._text(404, "%s has not been deployed yet." % game.title) diff --git a/tests/test_registry.py b/tests/test_registry.py index cbd3b26..a1c5b08 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -119,3 +119,21 @@ def test_bad_kind_or_isolation_is_refused(extra, message): with pytest.raises(registry.RegistryError) as error: registry.loads(registryText([game(**extra)])) assert message in str(error.value) + + +def test_canonical_is_optional_and_kept(): + loaded = registry.loads( + registryText([game(), game("ferry", canonical="https://example.com/play/night-ferry")]), domain=DOMAIN + ) + assert loaded.get("tidewater").canonical is None + assert loaded.get("ferry").canonical == "https://example.com/play/night-ferry" + + +@pytest.mark.parametrize( + "value", + ["http://example.com/play/x", "example.com/play/x", "https://example.com/a b", 'https://example.com/"x', "https://"], +) +def test_a_canonical_that_is_not_a_plain_https_url_is_refused(value): + with pytest.raises(registry.RegistryError) as error: + registry.loads(registryText([game(canonical=value)]), domain=DOMAIN) + assert "must be an https URL" in str(error.value) diff --git a/tests/test_server.py b/tests/test_server.py index 2363b0a..4db187e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -28,9 +28,9 @@ def arcade(tmp_path): registryPath.write_text( registryText( [ - game("tidewater", aliases=["tidewater.example.org"]), + game("tidewater", aliases=["tidewater.example.org"], canonical="https://example.com/play/tidewater"), game("overwinter", token_sha256=OTHER_SHA), - game("rps", kind="static"), + game("rps", kind="static", canonical="https://example.com/play/rps"), game("pthreads", kind="static", isolation="on"), ] ) @@ -516,3 +516,44 @@ def test_the_traefik_middlewares_are_configurable(tmp_path): server.shutdown() server.server_close() assert Config.fromEnvironment({}).traefikMiddlewares == ("secure-headers@file",) + + + +def test_a_game_with_a_canonical_page_names_it_on_html_only(arcade): + link = '; rel="canonical"' + upload(arcade, slug="rps", version="1", body=tarBundle(_site("1"))) + host = "rps." + DOMAIN + assert request(arcade, "GET", host, "/")[0].getheader("Link") == link + assert request(arcade, "HEAD", host, "/")[0].getheader("Link") == link + assert request(arcade, "GET", host, "/pkg/")[0].getheader("Link") == link + assert request(arcade, "GET", host, "/style.css")[0].getheader("Link") is None + assert request(arcade, "GET", host, "/missing")[0].getheader("Link") is None + + +def test_a_tak_game_and_its_alias_send_the_canonical_link(arcade): + link = '; rel="canonical"' + upload(arcade) + assert request(arcade, "GET", TIDEWATER, "/")[0].getheader("Link") == link + assert request(arcade, "GET", "tidewater.example.org", "/")[0].getheader("Link") == link + assert request(arcade, "GET", TIDEWATER, "/version.txt")[0].getheader("Link") is None + assert request(arcade, "GET", TIDEWATER, "/tak/boot.js")[0].getheader("Link") is None + + +def test_no_canonical_means_no_link_even_on_a_reused_connection(arcade): + upload(arcade, slug="rps", version="1", body=tarBundle(_site("1"))) + upload(arcade, slug="pthreads", version="1", body=tarBundle(_site("1"))) + connection = http.client.HTTPConnection("127.0.0.1", arcade.port, timeout=10) + connection.request("GET", "/", headers={"Host": "rps." + DOMAIN}) + first = connection.getresponse() + first.read() + assert first.getheader("Link") is not None + # The API host is not a game, so only the per-request reset clears it. + connection.request("GET", "/", headers={"Host": API}) + second = connection.getresponse() + second.read() + connection.request("GET", "/", headers={"Host": "pthreads." + DOMAIN}) + third = connection.getresponse() + third.read() + connection.close() + assert second.getheader("Link") is None + assert third.getheader("Link") is None