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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <url>; rel="canonical"`. Search engines then index one address for the game instead of
splitting it across `<slug>.play.<base>`, 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.<base>`: `{"version": "<arcade.__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
Expand Down
24 changes: 21 additions & 3 deletions src/arcade/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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: <url>; rel="canonical"`, which names one address
as the original. Leave it out and no Link header is sent.
"""

import re
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -261,6 +278,7 @@ def validate(entries, domain=None):
aliases=aliases,
kind=kind,
isolation=isolation,
canonical=canonical,
)
)
return Registry(games)
Expand Down
7 changes: 7 additions & 0 deletions src/arcade/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
18 changes: 18 additions & 0 deletions tests/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
45 changes: 43 additions & 2 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
]
)
Expand Down Expand Up @@ -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 = '<https://example.com/play/rps>; 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 = '<https://example.com/play/tidewater>; 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
Loading