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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<base>/`) and a `summary` `twitter:card` with its title
and description, so a shared link to `play.<base>` 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.<base>`: `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: <url>; rel="canonical"`. Search engines then index one address for the game instead of
Expand Down
24 changes: 22 additions & 2 deletions src/arcade/landing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <title>, 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 &mdash; 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; }
Expand Down Expand Up @@ -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 &mdash; browser games by Daniel McCoy Stephenson</title>
<meta name="description" content="Browser games by Daniel McCoy Stephenson, served by arcade. Nothing to install.">
<title>%(title)s</title>
<meta name="description" content="%(description)s">
<link rel="canonical" href="https://%(domain)s/">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Play">
<meta property="og:title" content="%(title)s">
<meta property="og:description" content="%(description)s">
<meta property="og:url" content="https://%(domain)s/">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="%(title)s">
<meta name="twitter:description" content="%(description)s">
<link rel="icon" href="data:,">
<style>%(style)s</style>
</head>
Expand All @@ -99,6 +117,8 @@ def render(registry, domain, portalUrl, deployed=lambda slug: True):
</html>
""" % {
"domain": escape(domain),
"title": TITLE,
"description": DESCRIPTION,
"style": _STYLE,
"portal": portal,
"portalLabel": portalLabel,
Expand Down
5 changes: 5 additions & 0 deletions src/arcade/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 24 additions & 0 deletions tests/test_landing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
'<meta name="description" content="Browser games by Daniel McCoy Stephenson',
'<link rel="canonical" href="https://play.example.com/">',
'<meta property="og:type" content="website">',
'<meta property="og:title" content="Play &mdash; browser games by Daniel McCoy Stephenson">',
'<meta property="og:description" content="Browser games by Daniel McCoy Stephenson',
'<meta property="og:url" content="https://play.example.com/">',
'<meta name="twitter:card" content="summary">',
'<meta name="twitter:title" content="Play &mdash;',
'<meta name="twitter:description" content="Browser games',
):
assert tag in page, tag
assert "localhost" not in page
# No image exists to preview, and none is invented.
assert "og:image" not in page and "twitter:image" not in page


def test_the_share_preview_url_follows_the_configured_domain():
page = landing.render(Registry([]), "play.danielstephenson.dev", "https://danielstephenson.dev/play").decode("utf-8")
assert '<meta property="og:url" content="https://play.danielstephenson.dev/">' in page
18 changes: 18 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, "/")
Expand Down
Loading